From 614d308ad824e623768231bb1031c5d550c29eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Wed, 3 Jul 2024 16:32:47 +0200 Subject: [PATCH 01/26] feat: tasks --- .../controllers/AutoTranslationController.kt | 2 +- .../api/v2/controllers/TaskController.kt | 301 +++ .../api/v2/controllers/UserTasksController.kt | 45 + .../v2/controllers/V2LanguagesController.kt | 5 +- .../controllers/project/ProjectsController.kt | 8 +- .../TranslationSuggestionController.kt | 19 +- .../CreateOrUpdateTranslationsFacade.kt | 6 +- .../TranslationCommentController.kt | 17 +- .../translation/TranslationsController.kt | 29 +- .../tolgee/component/KeyComplexEditHelper.kt | 8 +- .../io/tolgee/hateoas/task/TaskModel.kt | 27 + .../tolgee/hateoas/task/TaskModelAssembler.kt | 46 + .../hateoas/task/TaskPerUserReportModel.kt | 11 + .../task/TaskPerUserReportModelAssembler.kt | 24 + .../hateoas/task/TaskWithProjectModel.kt | 29 + .../task/TaskWithProjectModelAssembler.kt | 49 + .../hateoas/translations/KeyTaskViewModel.kt | 13 + .../translations/KeyTaskViewModelAssembler.kt | 24 + .../translations/KeyWithTranslationsModel.kt | 2 + .../KeyWithTranslationsModelAssembler.kt | 5 + .../TranslationViewModelAssembler.kt | 2 +- .../userAccount/SimpleUserAccountModel.kt | 2 + .../task/TaskControllerAssigneesTest.kt | 119 ++ .../task/TaskControllerPermissionsTest.kt | 101 + .../v2/controllers/task/TaskControllerTest.kt | 338 ++++ .../ProjectsControllerInvitationTest.kt | 16 +- .../repository/ProjectRepositoryTest.kt | 8 +- .../repository/UserAccountRepositoryTest.kt | 9 +- backend/data/build.gradle | 6 + .../io/tolgee/activity/data/ActivityType.kt | 7 + .../tolgee/component/task/TaskReportHelper.kt | 128 ++ .../kotlin/io/tolgee/constants/Message.kt | 3 + .../testDataBuilder/TestDataService.kt | 14 + .../testDataBuilder/builders/KeyBuilder.kt | 4 +- .../builders/ProjectBuilder.kt | 8 + .../testDataBuilder/builders/TaskBuilder.kt | 13 + .../builders/TaskKeyBuilder.kt | 11 + .../testDataBuilder/data/TaskTestData.kt | 198 ++ .../dtos/request/language/LanguageFilters.kt | 15 + .../dtos/request/project/ProjectFilters.kt | 15 + .../request/task/CalculateScopeRequest.kt | 13 + .../task/CreateMultipleTasksRequest.kt | 5 + .../dtos/request/task/CreateTaskRequest.kt | 34 + .../tolgee/dtos/request/task/TaskFilters.kt | 63 + .../dtos/request/task/TaskKeysResponse.kt | 5 + .../request/task/TranslationScopeFilters.kt | 12 + .../dtos/request/task/UpdateTaskKeyRequest.kt | 8 + .../request/task/UpdateTaskKeyResponse.kt | 6 + .../request/task/UpdateTaskKeysRequest.kt | 6 + .../dtos/request/task/UpdateTaskRequest.kt | 17 + .../request/translation/TranslationFilters.kt | 5 + .../request/userAccount/UserAccountFilters.kt | 15 + .../UserAccountPermissionsFilters.kt | 44 + .../io/tolgee/model/EmailVerification.kt | 9 +- .../main/kotlin/io/tolgee/model/Invitation.kt | 13 +- .../main/kotlin/io/tolgee/model/Language.kt | 4 + .../main/kotlin/io/tolgee/model/Project.kt | 2 +- .../kotlin/io/tolgee/model/UserAccount.kt | 17 +- .../model/enums/ProjectPermissionType.kt | 4 + .../kotlin/io/tolgee/model/enums/Scope.kt | 30 +- .../kotlin/io/tolgee/model/enums/TaskState.kt | 8 + .../kotlin/io/tolgee/model/enums/TaskType.kt | 6 + .../main/kotlin/io/tolgee/model/key/Key.kt | 4 + .../main/kotlin/io/tolgee/model/task/Task.kt | 67 + .../kotlin/io/tolgee/model/task/TaskKey.kt | 19 + .../kotlin/io/tolgee/model/task/TaskKeyId.kt | 13 + .../io/tolgee/model/views/KeyTaskView.kt | 12 + .../model/views/KeyWithTranslationsView.kt | 1 + .../io/tolgee/model/views/KeysScopeView.kt | 7 + .../model/views/TaskPerUserReportView.kt | 10 + .../io/tolgee/model/views/TaskScopeView.kt | 9 + .../tolgee/model/views/TaskWithScopeView.kt | 29 + .../model/views/TranslationToTaskView.kt | 13 + .../io/tolgee/repository/KeyRepository.kt | 5 + .../tolgee/repository/LanguageRepository.kt | 14 + .../io/tolgee/repository/ProjectRepository.kt | 21 + .../io/tolgee/repository/TaskKeyRepository.kt | 12 + .../io/tolgee/repository/TaskRepository.kt | 357 ++++ .../repository/TranslationRepository.kt | 12 + .../repository/UserAccountRepository.kt | 99 + .../kotlin/io/tolgee/service/TaskService.kt | 484 +++++ .../service/dataImport/StoredDataImporter.kt | 2 +- .../io/tolgee/service/key/KeyService.kt | 9 +- .../service/language/LanguageHardDeleter.kt | 43 +- .../service/language/LanguageService.kt | 4 +- .../tolgee/service/project/ProjectService.kt | 14 +- .../translationViewBuilder/QueryBase.kt | 2 + .../QueryGlobalFiltering.kt | 14 + .../TranslationViewDataProvider.kt | 4 + .../TranslationsViewQueryBuilder.kt | 3 + .../service/security/SecurityService.kt | 132 +- .../service/security/UserAccountService.kt | 36 +- .../main/resources/db/changelog/schema.xml | 78 +- .../io/tolgee/ProjectAuthControllerTest.kt | 2 +- e2e/cypress/common/export.ts | 2 + .../permissions/permissionsAdmin.1.cy.ts | 1 + .../permissions/permissionsAdmin.2.cy.ts | 1 + .../permissions/permissionsAdmin.3.cy.ts | 1 + .../permissions/permissionsBatchJobs.1.cy.ts | 3 - .../permissions/permissionsBatchJobs.2.cy.ts | 3 - .../permissionsServerAdmin.1.cy.ts | 1 + .../permissionsServerAdmin.2.cy.ts | 1 + .../permissionsServerAdmin.3.cy.ts | 1 + e2e/cypress/support/dataCyType.d.ts | 10 +- e2e/cypress/support/registerCommands.ts | 28 +- .../V2ProjectsInvitationControllerEeTest.kt | 12 +- webapp/package-lock.json | 345 ++-- webapp/package.json | 8 +- webapp/src/component/AutoTranslationIcon.tsx | 9 +- webapp/src/component/CustomIcons.tsx | 98 +- webapp/src/component/FakeInput.tsx | 26 + webapp/src/component/GlobalErrorModal.tsx | 4 +- webapp/src/component/HelpMenu.tsx | 23 +- .../PermissionsSettings/PermissionsMenu.tsx | 2 +- .../PermissionsSettings/ScopesInfo.tsx | 12 +- webapp/src/component/RootRouter.tsx | 5 + webapp/src/component/TranslationFlagIcon.tsx | 5 +- webapp/src/component/UserAccount.tsx | 43 + .../ActivityCompact/ActivityCompact.tsx | 4 +- .../ActivityDetail/ActivityDetailContent.tsx | 4 +- .../component/activity/activityEntities.tsx | 54 + .../src/component/activity/configuration.tsx | 56 + webapp/src/component/activity/formatTools.tsx | 9 + .../activity/references/AnyReference.tsx | 3 + .../activity/references/TaskReference.tsx | 33 + webapp/src/component/activity/types.tsx | 20 +- .../activity/types/getCommentStateChange.tsx | 4 +- .../activity/types/getDateChange.tsx | 53 + .../activity/types/getTaskStateChange.tsx | 47 + .../activity/types/getTaskTypeChange.tsx | 47 + .../billing/Plan/ShowAllFeatures.tsx | 7 +- webapp/src/component/billing/PlanFeature.tsx | 2 +- .../component/common/ClipboardCopyInput.tsx | 4 +- webapp/src/component/common/LabelHint.tsx | 4 +- webapp/src/component/common/Select.tsx | 46 + webapp/src/component/common/TextField.tsx | 47 +- .../component/common/avatar/ProfileAvatar.tsx | 4 +- .../common/buttons/SettingsIconButton.tsx | 4 +- .../form/LoadingCheckboxWithSkeleton.tsx | 6 +- .../common/form/PluralFormCheckbox.tsx | 8 +- .../epirationField/ExpirationDateField.tsx | 7 +- .../common/form/fields/SearchField.tsx | 6 +- .../component/common/form/fields/Select.tsx | 66 +- .../common/list/PaginatedHateoasList.tsx | 4 + .../src/component/common/list/SimpleList.tsx | 7 +- webapp/src/component/key/SvgKeys.tsx | 35 +- .../languages/FlagSelector/FlagSelector.tsx | 2 +- .../languages/LanguageAutocomplete.tsx | 10 +- .../component/languages/PreparedLanguage.tsx | 6 +- webapp/src/component/layout/BaseView.tsx | 42 +- .../component/layout/BaseViewAddButton.tsx | 4 +- webapp/src/component/layout/DashboardPage.tsx | 7 +- .../layout/HeaderSearchField.tsx} | 13 +- .../QuickStartGuide/QuickStartFinishStep.tsx | 8 +- .../QuickStartGuide/QuickStartGuide.tsx | 9 +- .../QuickStartGuide/QuickStartProgress.tsx | 6 +- .../layout/QuickStartGuide/QuickStartStep.tsx | 2 +- .../QuickStartTopBarButton.tsx | 4 +- .../layout/TopBanner/Announcement.tsx | 4 +- .../component/layout/TopBanner/TopBanner.tsx | 4 +- .../src/component/navigation/Navigation.tsx | 4 +- .../OrganizationPopover.tsx | 4 +- .../organizationSwitch/OrganizationSwitch.tsx | 8 +- .../ProjectSearchSelect.tsx | 104 ++ .../ProjectSearchSelectItem.tsx | 41 + .../ProjectSearchSelectPopover.tsx | 248 +++ .../component/projectSearchSelect/types.ts | 9 + .../searchSelect/SearchSelectContent.tsx | 6 +- .../searchSelect/SearchSelectMulti.tsx | 6 +- .../security/LanguagePermissionsMenu.tsx | 4 +- .../src/component/security/OAuthService.tsx | 11 +- webapp/src/component/security/RoleMenu.tsx | 2 +- .../component/security/SignUp/SignUpForm.tsx | 11 +- .../security/UserMenu/OrganizationSwitch.tsx | 2 +- .../component/security/UserMenu/ThemeItem.tsx | 6 +- .../security/UserMenu/UserPresentMenu.tsx | 31 +- webapp/src/component/slack/Connection.tsx | 4 +- .../src/component/slack/SlackConnectView.tsx | 9 +- webapp/src/component/task/BoardColumn.tsx | 85 + webapp/src/component/task/BoardItem.tsx | 138 ++ webapp/src/component/task/TaskAssignees.tsx | 73 + webapp/src/component/task/TaskDatePicker.tsx | 47 + webapp/src/component/task/TaskDetail.tsx | 266 +++ webapp/src/component/task/TaskId.tsx | 46 + webapp/src/component/task/TaskInfoItem.tsx | 19 + webapp/src/component/task/TaskItem.tsx | 154 ++ webapp/src/component/task/TaskLabel.tsx | 59 + webapp/src/component/task/TaskMenu.tsx | 234 +++ webapp/src/component/task/TaskScope.tsx | 90 + webapp/src/component/task/TaskState.tsx | 35 + webapp/src/component/task/TaskTooltip.tsx | 104 ++ .../src/component/task/TaskTooltipContent.tsx | 121 ++ webapp/src/component/task/TaskTypeChip.tsx | 33 + webapp/src/component/task/TasksBoard.tsx | 143 ++ .../assigneeSelect/AssigneeSearchSelect.tsx | 120 ++ .../AssigneeSearchSelectPopover.tsx | 255 +++ .../task/taskCreate/TaskCreateDialog.tsx | 325 ++++ .../component/task/taskCreate/TaskPreview.tsx | 156 ++ .../taskCreate/TranslationStateFilter.tsx | 134 ++ .../task/taskFilter/SubfilterAssignees.tsx | 46 + .../task/taskFilter/SubfilterLanguages.tsx | 71 + .../task/taskFilter/SubfilterProjects.tsx | 44 + .../component/task/taskFilter/SubmenuItem.tsx | 24 + .../component/task/taskFilter/TaskFilter.tsx | 215 +++ .../task/taskFilter/TaskFilterPopover.tsx | 144 ++ .../task/taskFilter/taskFilterUtils.ts | 10 + .../task/taskSelect/TaskSearchSelect.tsx | 131 ++ .../task/taskSelect/TaskSearchSelectItem.tsx | 25 + .../taskSelect/TaskSearchSelectPopover.tsx | 226 +++ webapp/src/component/task/taskSelect/types.ts | 5 + .../task/tasksHeader/TasksHeader.tsx | 16 + .../task/tasksHeader/TasksHeaderBig.tsx | 142 ++ .../task/tasksHeader/TasksHeaderCompact.tsx | 186 ++ webapp/src/component/task/utils.ts | 49 + .../translationFilters}/FiltersMenu.tsx | 23 +- .../translationFilters}/SubmenuMulti.tsx | 6 +- .../translationFilters}/SubmenuStates.tsx | 2 +- .../translationFilters/TranslationFilters.tsx | 173 ++ .../translationFilters/getActiveFilters.ts} | 8 +- .../translation/translationFilters}/tools.ts | 21 +- .../useAvailableFilters.tsx | 154 ++ .../translationFilters}/useFiltersContent.tsx | 29 +- .../src/constants/GlobalValidationSchema.tsx | 41 +- webapp/src/constants/links.tsx | 6 + .../usePermissionsStructure.ts | 11 + .../useScopeTranslations.tsx | 5 +- .../src/ee/billing/Invoices/InvoiceUsage.tsx | 4 +- ...OrganizationBillingTestClockHelperView.tsx | 4 +- .../AdministrationCloudPlansView.tsx | 2 +- .../AdministrationEePlansView.tsx | 2 +- .../components/CloudPlanForm.tsx | 1 + .../src/ee/billing/common/usage/ItemRow.tsx | 4 +- .../common/usage/UsageDialogButton.tsx | 4 +- webapp/src/ee/eeLicense/RefreshButton.tsx | 4 +- .../globalContext/useInitialDataService.ts | 17 + webapp/src/index.tsx | 2 - webapp/src/service/TranslationHooks.ts | 19 + webapp/src/service/apiSchema.generated.ts | 1635 ++++++++++++----- webapp/src/service/http/useQueryApi.ts | 25 +- webapp/src/svgs/icons/2lines-vertical.svg | 5 + webapp/src/svgs/icons/arrow-drop-down.svg | 3 + webapp/src/svgs/icons/arrow-right.svg | 3 + webapp/src/svgs/icons/back-translation.svg | 5 + webapp/src/svgs/icons/camera-sad.svg | 5 + webapp/src/svgs/icons/celebration.svg | 5 + .../svgs/icons/check-box-outline-blank.svg | 5 + webapp/src/svgs/icons/check-circle-dash.svg | 5 + webapp/src/svgs/icons/filter-lines2.svg | 5 + webapp/src/svgs/icons/github.svg | 5 + webapp/src/svgs/icons/google.svg | 5 + webapp/src/svgs/icons/integration.svg | 5 + webapp/src/svgs/icons/mt.svg | 5 + webapp/src/svgs/icons/other-languages.svg | 5 + webapp/src/svgs/icons/preview.svg | 5 + webapp/src/svgs/icons/rocket-filled.svg | 5 + webapp/src/svgs/icons/rocket.svg | 8 +- webapp/src/svgs/icons/stars.svg | 14 +- webapp/src/svgs/icons/suggestion.svg | 5 + webapp/src/svgs/icons/taskDetail.svg | 6 + webapp/src/svgs/icons/taskinfo.svg | 5 + webapp/src/svgs/icons/translation-memory.svg | 12 + .../translationTools/useStateTranslation.ts | 11 +- .../useTaskStateTranslation.ts | 23 + .../translationTools/useTaskTranslation.ts | 19 + .../components/OptionsButton.tsx | 4 +- webapp/src/views/myTasks/MyTasksBoard.tsx | 56 + webapp/src/views/myTasks/MyTasksList.tsx | 82 + webapp/src/views/myTasks/MyTasksView.tsx | 118 ++ webapp/src/views/myTasks/useMyBoardTask.tsx | 46 + .../organizations/apps/slack/SlackApp.tsx | 2 +- .../organizations/members/InvitationItem.tsx | 6 +- .../organizations/members/MemberItem.tsx | 6 +- .../members/RemoveUserButton.tsx | 4 +- webapp/src/views/projects/BaseProjectView.tsx | 2 +- .../projects/DashboardProjectListItem.tsx | 5 +- .../views/projects/ProjectListItemMenu.tsx | 4 +- webapp/src/views/projects/ProjectPage.tsx | 6 +- webapp/src/views/projects/ProjectRouter.tsx | 10 + webapp/src/views/projects/TaskRedirect.tsx | 45 + .../dashboard/LanguageStats/LanguageMenu.tsx | 4 +- .../projects/dashboard/ProjectDescription.tsx | 5 +- .../dashboard/ProjectSettingsRight.tsx | 4 +- .../projects/dashboard/ProjectTotals.tsx | 6 +- .../views/projects/developer/CopyUrlItem.tsx | 4 +- .../contentDelivery/CdAutoPublish.tsx | 19 +- .../developer/contentDelivery/CdList.tsx | 4 +- .../contentDelivery/CdPruneBeforePublish.tsx | 19 +- .../developer/storage/StorageList.tsx | 4 +- .../developer/webhook/WebhookItem.tsx | 8 +- .../developer/webhook/WebhookList.tsx | 4 +- .../export/components/LanguageSelector.tsx | 2 +- .../export/components/NestedSelector.tsx | 52 - .../components/SupportArraysSelector.tsx | 19 +- .../import/component/ImportAlertError.tsx | 4 +- .../ImportConflictResolutionDialog.tsx | 4 +- .../component/ImportConflictTranslation.tsx | 8 +- .../component/ImportConflictsDataHeader.tsx | 8 +- .../component/ImportConflictsSecondaryBar.tsx | 4 +- .../import/component/ImportFileDropzone.tsx | 14 +- .../component/ImportFileIssuesDialog.tsx | 4 +- .../import/component/ImportOperationTitle.tsx | 7 +- .../import/component/ImportResultRow.tsx | 39 +- .../import/component/ImportSettingsPanel.tsx | 11 +- .../component/ImportTranslationsDialog.tsx | 4 +- .../import/component/LanguageSelector.tsx | 8 +- .../integrate/component/ApiKeySelector.tsx | 10 +- .../src/views/projects/integrate/guides.tsx | 6 +- .../AiCustomization/AiExampleBanner.tsx | 4 +- .../AiCustomization/AiLanguagesTableRow.tsx | 8 +- .../AiCustomization/AiProjectDescription.tsx | 10 +- .../languages/AiCustomization/AiTips.tsx | 4 +- .../LanguageEdit/LanguageEditDialog.tsx | 4 +- .../MachineTranslation/LanguageRow.tsx | 4 +- .../MachineTranslation/ServiceAvatar.tsx | 7 +- .../projects/languages/ProjectLanguages.tsx | 4 +- .../members/component/InvitationItem.tsx | 6 +- .../component/RevokePermissionsButton.tsx | 4 +- .../project/components/BaseLanguageSelect.tsx | 1 + .../components/DefaultNamespaceSelect.tsx | 1 + .../components/ProjectTransferModal.tsx | 4 +- .../projects/projectMenu/ProjectMenu.tsx | 154 +- .../projects/projectMenu/SideMenuItem.tsx | 1 + .../projects/tasks/ProjectTasksBoard.tsx | 64 + .../views/projects/tasks/ProjectTasksView.tsx | 164 ++ webapp/src/views/projects/tasks/TasksList.tsx | 87 + .../projects/tasks/useProjectBoardTasks.tsx | 49 + .../BatchOperations/BatchOperations.tsx | 9 + .../BatchOperations/BatchSelect.tsx | 16 + .../BatchOperations/OperationTaskAddKeys.tsx | 60 + .../BatchOperations/OperationTaskCreate.tsx | 53 + .../OperationTaskRemoveKeys.tsx | 82 + .../OperationAbortButton.tsx | 4 +- .../OperationsSummary/OperationsSummary.tsx | 6 +- .../components/BatchOperationsSubmit.tsx | 6 +- .../getPreselectedLanguages.ts | 11 + .../translations/BatchOperations/types.ts | 3 + .../views/projects/translations/CellKey.tsx | 7 +- .../projects/translations/Filters/Filters.tsx | 168 -- .../Filters/useAvailableFilters.tsx | 136 -- .../KeyCreateForm/KeyCreateForm.tsx | 4 +- .../translations/KeyEdit/KeyEditModal.tsx | 4 +- .../Namespace/NamespaceContent.tsx | 2 +- .../Namespace/NamespaceRenameDialog.tsx | 2 +- .../Namespace/useNamespaceFilter.ts | 5 +- .../Screenshots/ScreenshotDropzone.tsx | 13 +- .../Screenshots/ScreenshotGallery.tsx | 7 +- .../Screenshots/ScreenshotThumbnail.tsx | 7 +- .../translations/Tags/CloseButton.tsx | 5 +- .../views/projects/translations/Tags/Tag.tsx | 10 +- .../projects/translations/Tags/TagAdd.tsx | 4 +- .../views/projects/translations/Tags/Tags.tsx | 5 +- .../translations/ToolsPanel/ToolsPanel.tsx | 22 +- .../translations/ToolsPanel/common/Panel.tsx | 30 +- .../translations/ToolsPanel/common/types.ts | 5 +- .../ToolsPanel/panels/Comments/Comment.tsx | 13 +- .../ToolsPanel/panels/Comments/Comments.tsx | 31 +- .../ToolsPanel/panels/History/HistoryItem.tsx | 4 +- .../ToolsPanel/panels/Tasks/Tasks.tsx | 79 + .../translations/ToolsPanel/panelsList.tsx | 37 +- .../translations/TranslationEditor.tsx | 7 +- .../TranslationHeader/TranslationControls.tsx | 27 +- .../TranslationControlsCompact.tsx | 65 +- .../TranslationsList/TranslationRead.tsx | 4 + .../TranslationsList/TranslationWrite.tsx | 10 +- .../TranslationsTable/TranslationRead.tsx | 3 + .../TranslationsTable/TranslationWrite.tsx | 6 + .../TranslationsTable/TranslationsTable.tsx | 6 +- .../translations/TranslationsToolbar.tsx | 6 +- .../translations/cell/ControlsButton.tsx | 5 + .../translations/cell/ControlsEditorMain.tsx | 95 +- .../translations/cell/ControlsEditorSmall.tsx | 32 +- .../translations/cell/ControlsKey.tsx | 12 +- .../translations/cell/ControlsTranslation.tsx | 48 +- .../projects/translations/cell/StateIcon.tsx | 9 +- .../cell/StateTransitionButtons.tsx | 14 +- .../translations/cell/TranslationFlags.tsx | 72 +- .../context/TranslationsContext.ts | 14 +- .../context/services/useEditService.tsx | 31 +- .../context/services/useStateService.tsx | 22 +- .../context/services/useTaskService.tsx | 80 + .../services/useTranslationsService.tsx | 4 +- .../shortcuts/useTranslationsShortcuts.ts | 40 +- .../projects/translations/context/types.ts | 7 + .../prefilters/ContainerPrefilter.tsx | 4 +- .../translations/prefilters/Prefilter.tsx | 3 + .../translations/prefilters/PrefilterTask.tsx | 106 ++ .../translations/prefilters/usePrefilter.ts | 9 + .../translations/useTranslationCell.ts | 33 +- .../userSettings/apiKeys/ApiKeyListItem.tsx | 4 +- .../apiKeys/GenerateApiKeyDialog.tsx | 1 + .../views/userSettings/pats/PatListItem.tsx | 4 +- 391 files changed, 12686 insertions(+), 1970 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/TaskController.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserTasksController.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModel.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModelAssembler.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModel.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModelAssembler.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModel.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModelAssembler.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModel.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModelAssembler.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerAssigneesTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerTest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/component/task/TaskReportHelper.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskBuilder.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskKeyBuilder.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TaskTestData.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectFilters.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CalculateScopeRequest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateMultipleTasksRequest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateTaskRequest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskFilters.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskKeysResponse.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TranslationScopeFilters.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyRequest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyResponse.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeysRequest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskRequest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountFilters.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountPermissionsFilters.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/enums/TaskState.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/enums/TaskType.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/task/Task.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/task/TaskKey.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/task/TaskKeyId.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/views/KeyTaskView.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/views/KeysScopeView.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/views/TaskPerUserReportView.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/views/TaskScopeView.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/views/TaskWithScopeView.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/views/TranslationToTaskView.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/repository/TaskKeyRepository.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt create mode 100644 webapp/src/component/FakeInput.tsx create mode 100644 webapp/src/component/UserAccount.tsx create mode 100644 webapp/src/component/activity/references/TaskReference.tsx create mode 100644 webapp/src/component/activity/types/getDateChange.tsx create mode 100644 webapp/src/component/activity/types/getTaskStateChange.tsx create mode 100644 webapp/src/component/activity/types/getTaskTypeChange.tsx create mode 100644 webapp/src/component/common/Select.tsx rename webapp/src/{views/projects/translations/TranslationHeader/TranslationsSearchField.tsx => component/layout/HeaderSearchField.tsx} (80%) create mode 100644 webapp/src/component/projectSearchSelect/ProjectSearchSelect.tsx create mode 100644 webapp/src/component/projectSearchSelect/ProjectSearchSelectItem.tsx create mode 100644 webapp/src/component/projectSearchSelect/ProjectSearchSelectPopover.tsx create mode 100644 webapp/src/component/projectSearchSelect/types.ts create mode 100644 webapp/src/component/task/BoardColumn.tsx create mode 100644 webapp/src/component/task/BoardItem.tsx create mode 100644 webapp/src/component/task/TaskAssignees.tsx create mode 100644 webapp/src/component/task/TaskDatePicker.tsx create mode 100644 webapp/src/component/task/TaskDetail.tsx create mode 100644 webapp/src/component/task/TaskId.tsx create mode 100644 webapp/src/component/task/TaskInfoItem.tsx create mode 100644 webapp/src/component/task/TaskItem.tsx create mode 100644 webapp/src/component/task/TaskLabel.tsx create mode 100644 webapp/src/component/task/TaskMenu.tsx create mode 100644 webapp/src/component/task/TaskScope.tsx create mode 100644 webapp/src/component/task/TaskState.tsx create mode 100644 webapp/src/component/task/TaskTooltip.tsx create mode 100644 webapp/src/component/task/TaskTooltipContent.tsx create mode 100644 webapp/src/component/task/TaskTypeChip.tsx create mode 100644 webapp/src/component/task/TasksBoard.tsx create mode 100644 webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx create mode 100644 webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx create mode 100644 webapp/src/component/task/taskCreate/TaskCreateDialog.tsx create mode 100644 webapp/src/component/task/taskCreate/TaskPreview.tsx create mode 100644 webapp/src/component/task/taskCreate/TranslationStateFilter.tsx create mode 100644 webapp/src/component/task/taskFilter/SubfilterAssignees.tsx create mode 100644 webapp/src/component/task/taskFilter/SubfilterLanguages.tsx create mode 100644 webapp/src/component/task/taskFilter/SubfilterProjects.tsx create mode 100644 webapp/src/component/task/taskFilter/SubmenuItem.tsx create mode 100644 webapp/src/component/task/taskFilter/TaskFilter.tsx create mode 100644 webapp/src/component/task/taskFilter/TaskFilterPopover.tsx create mode 100644 webapp/src/component/task/taskFilter/taskFilterUtils.ts create mode 100644 webapp/src/component/task/taskSelect/TaskSearchSelect.tsx create mode 100644 webapp/src/component/task/taskSelect/TaskSearchSelectItem.tsx create mode 100644 webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx create mode 100644 webapp/src/component/task/taskSelect/types.ts create mode 100644 webapp/src/component/task/tasksHeader/TasksHeader.tsx create mode 100644 webapp/src/component/task/tasksHeader/TasksHeaderBig.tsx create mode 100644 webapp/src/component/task/tasksHeader/TasksHeaderCompact.tsx create mode 100644 webapp/src/component/task/utils.ts rename webapp/src/{views/projects/translations/Filters => component/translation/translationFilters}/FiltersMenu.tsx (69%) rename webapp/src/{views/projects/translations/Filters => component/translation/translationFilters}/SubmenuMulti.tsx (93%) rename webapp/src/{views/projects/translations/Filters => component/translation/translationFilters}/SubmenuStates.tsx (97%) create mode 100644 webapp/src/component/translation/translationFilters/TranslationFilters.tsx rename webapp/src/{views/projects/translations/Filters/useActiveFilters.ts => component/translation/translationFilters/getActiveFilters.ts} (69%) rename webapp/src/{views/projects/translations/Filters => component/translation/translationFilters}/tools.ts (81%) create mode 100644 webapp/src/component/translation/translationFilters/useAvailableFilters.tsx rename webapp/src/{views/projects/translations/Filters => component/translation/translationFilters}/useFiltersContent.tsx (77%) create mode 100644 webapp/src/svgs/icons/2lines-vertical.svg create mode 100644 webapp/src/svgs/icons/arrow-drop-down.svg create mode 100644 webapp/src/svgs/icons/arrow-right.svg create mode 100644 webapp/src/svgs/icons/back-translation.svg create mode 100644 webapp/src/svgs/icons/camera-sad.svg create mode 100644 webapp/src/svgs/icons/celebration.svg create mode 100644 webapp/src/svgs/icons/check-box-outline-blank.svg create mode 100644 webapp/src/svgs/icons/check-circle-dash.svg create mode 100644 webapp/src/svgs/icons/filter-lines2.svg create mode 100644 webapp/src/svgs/icons/github.svg create mode 100644 webapp/src/svgs/icons/google.svg create mode 100644 webapp/src/svgs/icons/integration.svg create mode 100644 webapp/src/svgs/icons/mt.svg create mode 100644 webapp/src/svgs/icons/other-languages.svg create mode 100644 webapp/src/svgs/icons/preview.svg create mode 100644 webapp/src/svgs/icons/rocket-filled.svg create mode 100644 webapp/src/svgs/icons/suggestion.svg create mode 100644 webapp/src/svgs/icons/taskDetail.svg create mode 100644 webapp/src/svgs/icons/taskinfo.svg create mode 100644 webapp/src/svgs/icons/translation-memory.svg create mode 100644 webapp/src/translationTools/useTaskStateTranslation.ts create mode 100644 webapp/src/translationTools/useTaskTranslation.ts create mode 100644 webapp/src/views/myTasks/MyTasksBoard.tsx create mode 100644 webapp/src/views/myTasks/MyTasksList.tsx create mode 100644 webapp/src/views/myTasks/MyTasksView.tsx create mode 100644 webapp/src/views/myTasks/useMyBoardTask.tsx create mode 100644 webapp/src/views/projects/TaskRedirect.tsx delete mode 100644 webapp/src/views/projects/export/components/NestedSelector.tsx create mode 100644 webapp/src/views/projects/tasks/ProjectTasksBoard.tsx create mode 100644 webapp/src/views/projects/tasks/ProjectTasksView.tsx create mode 100644 webapp/src/views/projects/tasks/TasksList.tsx create mode 100644 webapp/src/views/projects/tasks/useProjectBoardTasks.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationTaskAddKeys.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationTaskRemoveKeys.tsx delete mode 100644 webapp/src/views/projects/translations/Filters/Filters.tsx delete mode 100644 webapp/src/views/projects/translations/Filters/useAvailableFilters.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/Tasks/Tasks.tsx create mode 100644 webapp/src/views/projects/translations/context/services/useTaskService.tsx create mode 100644 webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AutoTranslationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AutoTranslationController.kt index 5b85d19ea3..6de7c962d5 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AutoTranslationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AutoTranslationController.kt @@ -96,7 +96,7 @@ When no languages provided, it translates only untranslated languages.""", languagesToTranslate: Set, ) { keyService.checkInProject(key, projectHolder.project.id) - securityService.checkLanguageTranslatePermissionsByTag(languagesToTranslate, projectHolder.project.id) + securityService.checkLanguageTranslatePermissionsByTag(languagesToTranslate, projectHolder.project.id, key.id) } private fun getAllLanguagesToTranslate(): Set { 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 new file mode 100644 index 0000000000..655df36e8b --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/TaskController.kt @@ -0,0 +1,301 @@ +package io.tolgee.api.v2.controllers + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.activity.RequestActivity +import io.tolgee.activity.data.ActivityType +import io.tolgee.dtos.request.task.* +import io.tolgee.dtos.request.userAccount.UserAccountPermissionsFilters +import io.tolgee.hateoas.task.TaskModel +import io.tolgee.hateoas.task.TaskModelAssembler +import io.tolgee.hateoas.task.TaskPerUserReportModel +import io.tolgee.hateoas.task.TaskPerUserReportModelAssembler +import io.tolgee.hateoas.userAccount.SimpleUserAccountModel +import io.tolgee.hateoas.userAccount.SimpleUserAccountModelAssembler +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.Scope +import io.tolgee.model.enums.TaskState +import io.tolgee.model.views.KeysScopeView +import io.tolgee.model.views.TaskWithScopeView +import io.tolgee.openApiDocs.OpenApiOrderExtension +import io.tolgee.security.ProjectHolder +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authorization.RequiresProjectPermissions +import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.TaskService +import io.tolgee.service.security.SecurityService +import io.tolgee.service.security.UserAccountService +import jakarta.validation.Valid +import org.springdoc.core.annotations.ParameterObject +import org.springframework.core.io.ByteArrayResource +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PagedResourcesAssembler +import org.springframework.hateoas.PagedModel +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping( + value = [ + "/v2/projects/{projectId}/tasks", + "/v2/projects/tasks", + ], +) +@Tag(name = "Tasks", description = "Manipulates tasks") +@OpenApiOrderExtension(7) +class TaskController( + private val taskService: TaskService, + private val taskModelAssembler: TaskModelAssembler, + private val pagedTaskResourcesAssembler: PagedResourcesAssembler, + private val projectHolder: ProjectHolder, + private val userAccountService: UserAccountService, + private val userAccountModelAssembler: SimpleUserAccountModelAssembler, + private val pagedUserResourcesAssembler: PagedResourcesAssembler, + private val taskPerUserReportModelAssembler: TaskPerUserReportModelAssembler, + private val securityService: SecurityService, +) { + @GetMapping("") + @Operation(summary = "Get tasks") + @RequiresProjectPermissions([Scope.TASKS_VIEW]) + @AllowApiAccess + fun getTasks( + @ParameterObject + filters: TaskFilters, + @ParameterObject + pageable: Pageable, + @RequestParam("search", required = false) + search: String?, + ): PagedModel { + val tasks = taskService.getAllPaged(projectHolder.projectEntity, pageable, search, filters) + return pagedTaskResourcesAssembler.toModel(tasks, taskModelAssembler) + } + + @PostMapping("") + @Operation(summary = "Create task") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + @RequestActivity(ActivityType.TASK_CREATE) + fun createTask( + @RequestBody @Valid + dto: CreateTaskRequest, + @ParameterObject + filters: TranslationScopeFilters, + ): TaskModel { + val task = taskService.createTask(projectHolder.projectEntity, dto, filters) + return taskModelAssembler.toModel(task) + } + + @GetMapping("/{taskNumber}") + @Operation(summary = "Get task") + @UseDefaultPermissions + @AllowApiAccess + fun getTask( + @PathVariable + taskNumber: Long, + ): TaskModel { + // user can view tasks assigned to him + securityService.hasTaskViewScopeOrIsAssigned(projectHolder.projectEntity.id, taskNumber) + val task = taskService.getTask(projectHolder.projectEntity, taskNumber) + return taskModelAssembler.toModel(task) + } + + @PutMapping("/{taskNumber}") + @Operation(summary = "Update task") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + @RequestActivity(ActivityType.TASK_UPDATE) + fun updateTask( + @PathVariable + taskNumber: Long, + @RequestBody @Valid + dto: UpdateTaskRequest, + ): TaskModel { + val task = taskService.updateTask(projectHolder.projectEntity, taskNumber, dto) + return taskModelAssembler.toModel(task) + } + + @GetMapping("/{taskNumber}/per-user-report") + @Operation(summary = "Report who did what") + @UseDefaultPermissions + @AllowApiAccess + fun getPerUserReport( + @PathVariable + taskNumber: Long, + ): List { + securityService.hasTaskViewScopeOrIsAssigned(projectHolder.projectEntity.id, taskNumber) + + val result = taskService.getReport(projectHolder.projectEntity, taskNumber) + return result.map { taskPerUserReportModelAssembler.toModel(it) } + } + + @GetMapping("/{taskNumber}/csv-report") + @Operation(summary = "Report who did what") + @UseDefaultPermissions + @AllowApiAccess + fun getCsvReport( + @PathVariable + taskNumber: Long, + ): ResponseEntity { + securityService.hasTaskViewScopeOrIsAssigned(projectHolder.projectEntity.id, taskNumber) + val byteArray = taskService.getExcelFile(projectHolder.projectEntity, taskNumber) + val resource = ByteArrayResource(byteArray) + + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_OCTET_STREAM + headers.setContentDispositionFormData("attachment", "report.xlsx") + headers.contentLength = byteArray.size.toLong() + + return ResponseEntity(resource, headers, HttpStatus.OK) + } + + @GetMapping("/{taskNumber}/keys") + @Operation(summary = "Get task keys") + @UseDefaultPermissions + @AllowApiAccess + fun getTaskKeys( + @PathVariable + taskNumber: Long, + ): TaskKeysResponse { + securityService.hasTaskViewScopeOrIsAssigned(projectHolder.projectEntity.id, taskNumber) + return TaskKeysResponse( + keys = taskService.getTaskKeys(projectHolder.projectEntity, taskNumber), + ) + } + + @PutMapping("/{taskNumber}/keys") + @Operation(summary = "Add or remove task keys") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + @RequestActivity(ActivityType.TASK_KEYS_UPDATE) + fun updateTaskKeys( + @PathVariable + taskNumber: Long, + @RequestBody @Valid + dto: UpdateTaskKeysRequest, + ) { + taskService.updateTaskKeys(projectHolder.projectEntity, taskNumber, dto) + } + + @GetMapping("/{taskNumber}/blocking-tasks") + @Operation(summary = "Get task ids which block this task") + @UseDefaultPermissions + @AllowApiAccess + fun getBlockingTasks( + @PathVariable + taskNumber: Long, + ): List { + return taskService.getBlockingTasks(projectHolder.projectEntity, taskNumber) + } + + @PostMapping("/{taskNumber}/finish") + @Operation(summary = "Finish task") + // permissions checked inside + @UseDefaultPermissions + @AllowApiAccess + @RequestActivity(ActivityType.TASK_FINISH) + fun finishTask( + @PathVariable + taskNumber: Long, + ): TaskModel { + // user can only finish tasks assigned to him + securityService.hasTaskEditScopeOrIsAssigned(projectHolder.projectEntity.id, taskNumber) + val task = taskService.setTaskState(projectHolder.projectEntity, taskNumber, TaskState.DONE) + return taskModelAssembler.toModel(task) + } + + @PostMapping("/{taskNumber}/close") + @Operation(summary = "Close task") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + @RequestActivity(ActivityType.TASK_CLOSE) + fun closeTask( + @PathVariable + taskNumber: Long, + ): TaskModel { + val task = taskService.setTaskState(projectHolder.projectEntity, taskNumber, TaskState.CLOSED) + return taskModelAssembler.toModel(task) + } + + @PostMapping("/{taskNumber}/reopen") + @Operation(summary = "Reopen task") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + @RequestActivity(ActivityType.TASK_REOPEN) + fun reopenTask( + @PathVariable + taskNumber: Long, + ): TaskModel { + val task = taskService.setTaskState(projectHolder.projectEntity, taskNumber, TaskState.IN_PROGRESS) + return taskModelAssembler.toModel(task) + } + + @PutMapping("/{taskNumber}/keys/{keyId}") + @Operation(summary = "Update task key") + // permissions checked inside + @UseDefaultPermissions + @AllowApiAccess + fun updateTaskKey( + @PathVariable + taskNumber: Long, + @PathVariable + keyId: Long, + @RequestBody @Valid + dto: UpdateTaskKeyRequest, + ): UpdateTaskKeyResponse { + // user can only update tasks assigned to him + securityService.hasTaskEditScopeOrIsAssigned(projectHolder.projectEntity.id, taskNumber) + return taskService.updateTaskKey(projectHolder.projectEntity, taskNumber, keyId, dto) + } + + @PostMapping("/create-multiple") + @Operation(summary = "Create multiple tasks") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + @RequestActivity(ActivityType.TASKS_CREATE) + fun createTasks( + @RequestBody @Valid + dto: CreateMultipleTasksRequest, + @ParameterObject + filters: TranslationScopeFilters, + ) { + taskService.createMultipleTasks(projectHolder.projectEntity, dto.tasks, filters) + } + + @PostMapping("/calculate-scope") + @Operation(summary = "Calculate scope") + @RequiresProjectPermissions([Scope.TASKS_VIEW]) + @AllowApiAccess + fun calculateScope( + @RequestBody @Valid + dto: CalculateScopeRequest, + @ParameterObject + filters: TranslationScopeFilters, + ): KeysScopeView { + return taskService.calculateScope(projectHolder.projectEntity, dto, filters) + } + + @GetMapping("/possible-assignees") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + fun getPossibleAssignees( + @ParameterObject + filters: UserAccountPermissionsFilters, + @ParameterObject + pageable: Pageable, + @RequestParam("search", required = false) + search: String?, + ): PagedModel { + val users = + userAccountService.findWithMinimalPermissions( + filters, + projectHolder.projectEntity.id, + search, + pageable, + ) + return pagedUserResourcesAssembler.toModel(users, userAccountModelAssembler) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserTasksController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserTasksController.kt new file mode 100644 index 0000000000..6f44364f45 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserTasksController.kt @@ -0,0 +1,45 @@ +package io.tolgee.api.v2.controllers + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.dtos.request.task.TaskFilters +import io.tolgee.hateoas.task.TaskWithProjectModel +import io.tolgee.hateoas.task.TaskWithProjectModelAssembler +import io.tolgee.model.views.TaskWithScopeView +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.TaskService +import org.springdoc.core.annotations.ParameterObject +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PagedResourcesAssembler +import org.springframework.hateoas.PagedModel +import org.springframework.web.bind.annotation.* + +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/user-tasks"]) +@Tag(name = "User tasks") +class UserTasksController( + private val taskService: TaskService, + private val authenticationFacade: AuthenticationFacade, + private val pagedTaskResourcesAssembler: PagedResourcesAssembler, + private val taskWithProjectModelAssembler: TaskWithProjectModelAssembler, +) { + @GetMapping("") + @Operation(summary = "Get user tasks") + @UseDefaultPermissions + @AllowApiAccess + fun getTasks( + @ParameterObject + filters: TaskFilters, + @ParameterObject + pageable: Pageable, + @RequestParam("search", required = false) + search: String?, + ): PagedModel { + val user = authenticationFacade.authenticatedUser + val tasks = taskService.getUserTasksPaged(user.id, pageable, search, filters) + return pagedTaskResourcesAssembler.toModel(tasks, taskWithProjectModelAssembler) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2LanguagesController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2LanguagesController.kt index 0deed2c748..f67d73b4d3 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2LanguagesController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2LanguagesController.kt @@ -12,6 +12,7 @@ import io.tolgee.component.LanguageValidator import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.request.LanguageRequest +import io.tolgee.dtos.request.language.LanguageFilters import io.tolgee.exceptions.BadRequestException import io.tolgee.hateoas.language.LanguageModel import io.tolgee.hateoas.language.LanguageModelAssembler @@ -95,8 +96,10 @@ class V2LanguagesController( fun getAll( @PathVariable("projectId") pathProjectId: Long?, @ParameterObject @SortDefault("tag") pageable: Pageable, + @ParameterObject + filters: LanguageFilters, ): PagedModel { - val data = languageService.getPaged(projectHolder.project.id, pageable) + val data = languageService.getPaged(projectHolder.project.id, pageable, filters) return pagedAssembler.toModel(data, languageModelAssembler) } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt index a13954df2a..e2b93807d7 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt @@ -11,6 +11,7 @@ import io.tolgee.activity.data.ActivityType import io.tolgee.constants.Message import io.tolgee.dtos.request.project.CreateProjectRequest import io.tolgee.dtos.request.project.EditProjectRequest +import io.tolgee.dtos.request.project.ProjectFilters import io.tolgee.dtos.request.project.SetPermissionLanguageParams import io.tolgee.exceptions.BadRequestException import io.tolgee.facade.ProjectPermissionFacade @@ -114,10 +115,13 @@ class ProjectsController( @AllowApiAccess(tokenType = AuthTokenType.ONLY_PAT) @OpenApiOrderExtension(3) fun getAll( - @ParameterObject pageable: Pageable, + @ParameterObject + filters: ProjectFilters, + @ParameterObject + pageable: Pageable, @RequestParam("search") search: String?, ): PagedModel { - val projects = projectService.findPermittedInOrganizationPaged(pageable, search) + val projects = projectService.findPermittedInOrganizationPaged(pageable, search, filters = filters) return arrayResourcesAssembler.toModel(projects, projectModelAssembler) } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt index 0fe7ad5ca0..acb5875078 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt @@ -15,6 +15,7 @@ import io.tolgee.model.views.TranslationMemoryItemView import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authorization.RequiresProjectPermissions +import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.key.KeyService import io.tolgee.service.language.LanguageService import io.tolgee.service.security.SecurityService @@ -72,12 +73,18 @@ class TranslationSuggestionController( "If an error occurs when for any service provider used," + " the error information is returned as a part of the result item, while the response has 200 status code.", ) - @RequiresProjectPermissions([Scope.TRANSLATIONS_EDIT]) + @UseDefaultPermissions @AllowApiAccess fun suggestMachineTranslationsStreaming( @RequestBody @Valid dto: SuggestRequestDto, ): ResponseEntity { + securityService.checkScopeOrAssignedToTask( + Scope.TRANSLATIONS_EDIT, + projectHolder.project.id, + dto.targetLanguageId, + dto.keyId ?: -1, + ) return ResponseEntity.ok().disableAccelBuffering().body( machineTranslationSuggestionFacade.suggestStreaming(dto), ) @@ -90,17 +97,21 @@ class TranslationSuggestionController( "Suggests machine translations from translation memory. " + "The result is always sorted by similarity, so sorting is not supported.", ) - @RequiresProjectPermissions([Scope.TRANSLATIONS_EDIT]) + @UseDefaultPermissions @AllowApiAccess fun suggestTranslationMemory( @RequestBody @Valid dto: SuggestRequestDto, @ParameterObject pageable: Pageable, ): PagedModel { + securityService.checkScopeOrAssignedToTask( + Scope.TRANSLATIONS_EDIT, + projectHolder.project.id, + dto.targetLanguageId, + dto.keyId ?: -1, + ) val targetLanguage = languageService.get(dto.targetLanguageId, projectHolder.project.id) - securityService.checkLanguageTranslatePermission(projectHolder.project.id, listOf(targetLanguage.id)) - val data = dto.baseText?.let { baseText -> translationMemoryService.getSuggestions( diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt index 5383a4466a..532694b329 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt @@ -75,7 +75,11 @@ class CreateOrUpdateTranslationsFacade( key: Key? = null, ): SetTranslationsResponseModel { val keyNotNull = key ?: keyService.get(projectHolder.project.id, dto.key, dto.namespace) - securityService.checkLanguageTranslatePermissionsByTag(dto.translations.keys, projectHolder.project.id) + securityService.checkLanguageTranslatePermissionsByTag( + dto.translations.keys, + projectHolder.project.id, + keyNotNull.id, + ) val modifiedTranslations = translationService.setForKey(keyNotNull, dto.translations) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationCommentController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationCommentController.kt index 27dbd9b502..8c1aed389c 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationCommentController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationCommentController.kt @@ -127,13 +127,20 @@ class TranslationCommentController( @PutMapping(value = ["{translationId}/comments/{commentId}/set-state/{state}"]) @Operation(summary = "Set state of translation comment") @RequestActivity(ActivityType.TRANSLATION_COMMENT_SET_STATE) - @RequiresProjectPermissions([Scope.TRANSLATIONS_COMMENTS_SET_STATE]) + @UseDefaultPermissions @AllowApiAccess fun setState( @PathVariable translationId: Long, @PathVariable commentId: Long, @PathVariable state: TranslationCommentState, ): TranslationCommentModel { + val translation = translationService.get(translationId) + securityService.checkScopeOrAssignedToTask( + Scope.TRANSLATIONS_COMMENTS_SET_STATE, + projectHolder.project.id, + translation.language.id, + translation.key.id, + ) val comment = translationCommentService.getWithAuthorFetched(projectHolder.project.id, translationId, commentId) translationCommentService.setState(comment, state) return translationCommentModelAssembler.toModel(comment) @@ -174,12 +181,18 @@ class TranslationCommentController( description = "Creates a translation comment. Empty translation is stored, when not exists.", ) @RequestActivity(ActivityType.TRANSLATION_COMMENT_ADD) - @RequiresProjectPermissions([Scope.TRANSLATIONS_COMMENTS_ADD]) + @UseDefaultPermissions @AllowApiAccess fun create( @RequestBody @Valid dto: TranslationCommentWithLangKeyDto, ): ResponseEntity { + securityService.checkScopeOrAssignedToTask( + Scope.TRANSLATIONS_COMMENTS_ADD, + projectHolder.project.id, + dto.languageId, + dto.keyId, + ) val translation = translationService.getOrCreate(dto.keyId, dto.languageId) if (translation.key.project.id != projectHolder.project.id) { throw BadRequestException(io.tolgee.constants.Message.KEY_NOT_FROM_PROJECT) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt index 65a9e11ae4..6cf6635418 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt @@ -31,6 +31,7 @@ import io.tolgee.hateoas.translations.TranslationModelAssembler import io.tolgee.model.enums.AssignableTranslationState import io.tolgee.model.enums.Scope import io.tolgee.model.translation.Translation +import io.tolgee.model.views.KeyTaskView import io.tolgee.model.views.KeyWithTranslationsView import io.tolgee.openApiDocs.OpenApiOrderExtension import io.tolgee.security.ProjectHolder @@ -38,6 +39,7 @@ import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.TaskService import io.tolgee.service.key.ScreenshotService import io.tolgee.service.language.LanguageService import io.tolgee.service.queryBuilders.CursorUtil @@ -100,6 +102,7 @@ class TranslationsController( private val activityService: ActivityService, private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager, private val createOrUpdateTranslationsFacade: CreateOrUpdateTranslationsFacade, + private val taskService: TaskService, ) : IController { @GetMapping(value = ["/{languages}"]) @Operation( @@ -176,7 +179,7 @@ When null, resulting file will be a flat key-value object. @PutMapping("") @Operation(summary = "Update translations for existing key", description = "Sets translations for existing key") @RequestActivity(ActivityType.SET_TRANSLATIONS) - @RequiresProjectPermissions([Scope.TRANSLATIONS_EDIT]) + @UseDefaultPermissions @AllowApiAccess @OpenApiOrderExtension(2) fun setTranslations( @@ -206,7 +209,7 @@ When null, resulting file will be a flat key-value object. @PutMapping("/{translationId}/set-state/{state}") @Operation(summary = "Set translation state") @RequestActivity(ActivityType.SET_TRANSLATION_STATE) - @RequiresProjectPermissions([Scope.TRANSLATIONS_STATE_EDIT]) + @UseDefaultPermissions @AllowApiAccess fun setTranslationState( @PathVariable translationId: Long, @@ -253,6 +256,7 @@ When null, resulting file will be a flat key-value object. .getViewData(projectHolder.project.id, pageableWithSort, params, languages) addScreenshotsToResponse(data) + addTasksToResponse(data) val cursor = if (data.content.isNotEmpty()) CursorUtil.getCursor(data.content.last(), data.sort) else null return pagedAssembler.toTranslationModel(data, languages, cursor) @@ -270,6 +274,27 @@ When null, resulting file will be a flat key-value object. data.content.forEach { it.screenshots = keysWithScreenshots[it.keyId] ?: listOf() } } + private fun addTasksToResponse(data: Page) { + val user = authenticationFacade.authenticatedUser + val keyIds = data.content.map { key -> key.keyId } + + val translationsWithTasks = taskService.getKeysWithTasks(user.id, keyIds) + + data.content.forEach { key -> + key.tasks = + translationsWithTasks[key.keyId]?.map { + KeyTaskView( + it.taskNumber, + it.languageId, + it.languageTag, + it.taskDone, + it.taskAssigned, + it.taskType, + ) + } + } + } + @PutMapping(value = ["/{translationId:[0-9]+}/dismiss-auto-translated-state"]) @Operation(summary = "Dismiss auto-translated", description = """Removes "auto translated" indication""") @RequestActivity(ActivityType.DISMISS_AUTO_TRANSLATED_STATE) diff --git a/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt b/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt index d05c4cd20a..c55822be6c 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt @@ -170,7 +170,11 @@ class KeyComplexEditHelper( private fun doStateUpdate() { if (areStatesModified) { - securityService.checkLanguageChangeStatePermissionsByLanguageId(modifiedStates!!.keys, projectHolder.project.id) + securityService.checkLanguageChangeStatePermissionsByLanguageId( + modifiedStates!!.keys, + projectHolder.project.id, + key.id, + ) translationService.setStateBatch( states = modifiedStates!!.map { @@ -187,10 +191,10 @@ class KeyComplexEditHelper( private fun doTranslationsUpdate() { if (modifiedTranslations != null && areTranslationsModified) { - projectHolder.projectEntity.checkTranslationsEditPermission() securityService.checkLanguageTranslatePermissionsByLanguageId( modifiedTranslations!!.keys, projectHolder.project.id, + keyId, ) val modifiedTranslations = getModifiedTranslationsByTag() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModel.kt new file mode 100644 index 0000000000..d971a271e9 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModel.kt @@ -0,0 +1,27 @@ +package io.tolgee.hateoas.task + +import io.tolgee.hateoas.language.LanguageModel +import io.tolgee.hateoas.userAccount.SimpleUserAccountModel +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "tasks", itemRelation = "task") +class TaskModel( + var number: Long = 0L, + var name: String = "", + var description: String = "", + var type: TaskType = TaskType.TRANSLATE, + var language: LanguageModel, + var dueDate: Long? = null, + var assignees: MutableSet = mutableSetOf(), + var totalItems: Long = 0, + var doneItems: Long = 0, + var baseWordCount: Long = 0, + var baseCharacterCount: Long = 0, + var author: SimpleUserAccountModel? = null, + var createdAt: Long? = 0, + var closedAt: Long? = null, + var state: TaskState = TaskState.IN_PROGRESS, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModelAssembler.kt new file mode 100644 index 0000000000..4efe83f4d1 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskModelAssembler.kt @@ -0,0 +1,46 @@ +package io.tolgee.hateoas.task + +import io.tolgee.api.v2.controllers.TaskController +import io.tolgee.dtos.cacheable.LanguageDto +import io.tolgee.hateoas.language.LanguageModelAssembler +import io.tolgee.hateoas.userAccount.SimpleUserAccountModelAssembler +import io.tolgee.model.views.TaskWithScopeView +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class TaskModelAssembler( + private val simpleUserAccountModelAssembler: SimpleUserAccountModelAssembler, + private val languageModelAssembler: LanguageModelAssembler, +) : RepresentationModelAssemblerSupport( + TaskController::class.java, + TaskModel::class.java, + ) { + override fun toModel(entity: TaskWithScopeView): TaskModel { + return TaskModel( + number = entity.number, + name = entity.name, + description = entity.description, + type = entity.type, + language = + entity.language.let { + languageModelAssembler.toModel( + LanguageDto.fromEntity( + it, + entity.project.baseLanguage?.id, + ), + ) + }, + dueDate = entity.dueDate?.time, + assignees = entity.assignees.map { simpleUserAccountModelAssembler.toModel(it) }.toMutableSet(), + author = entity.author?.let { simpleUserAccountModelAssembler.toModel(it) }, + createdAt = entity.createdAt?.time, + closedAt = entity.closedAt?.time, + totalItems = entity.totalItems, + doneItems = entity.doneItems, + baseWordCount = entity.baseWordCount, + baseCharacterCount = entity.baseCharacterCount, + state = entity.state, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModel.kt new file mode 100644 index 0000000000..e8e166d8e9 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModel.kt @@ -0,0 +1,11 @@ +package io.tolgee.hateoas.task + +import io.tolgee.hateoas.userAccount.SimpleUserAccountModel +import org.springframework.hateoas.RepresentationModel + +class TaskPerUserReportModel( + var user: SimpleUserAccountModel, + var doneItems: Long, + var baseCharacterCount: Long, + var baseWordCount: Long, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModelAssembler.kt new file mode 100644 index 0000000000..c94c15a097 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskPerUserReportModelAssembler.kt @@ -0,0 +1,24 @@ +package io.tolgee.hateoas.task + +import io.tolgee.api.v2.controllers.TaskController +import io.tolgee.hateoas.userAccount.SimpleUserAccountModelAssembler +import io.tolgee.model.views.TaskPerUserReportView +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class TaskPerUserReportModelAssembler( + private val simpleUserAccountModelAssembler: SimpleUserAccountModelAssembler, +) : RepresentationModelAssemblerSupport( + TaskController::class.java, + TaskPerUserReportModel::class.java, + ) { + override fun toModel(entity: TaskPerUserReportView): TaskPerUserReportModel { + return TaskPerUserReportModel( + user = simpleUserAccountModelAssembler.toModel(entity.user), + doneItems = entity.doneItems, + baseCharacterCount = entity.baseCharacterCount, + baseWordCount = entity.baseWordCount, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModel.kt new file mode 100644 index 0000000000..ca40b0d6fc --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModel.kt @@ -0,0 +1,29 @@ +package io.tolgee.hateoas.task + +import io.tolgee.hateoas.language.LanguageModel +import io.tolgee.hateoas.project.SimpleProjectModel +import io.tolgee.hateoas.userAccount.SimpleUserAccountModel +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "tasks", itemRelation = "task") +data class TaskWithProjectModel( + var number: Long = 0L, + var name: String = "", + var description: String = "", + var type: TaskType = TaskType.TRANSLATE, + var language: LanguageModel, + var dueDate: Long? = null, + var assignees: MutableSet = mutableSetOf(), + var totalItems: Long = 0, + var doneItems: Long = 0, + var baseWordCount: Long = 0, + var baseCharacterCount: Long = 0, + var author: SimpleUserAccountModel? = null, + var createdAt: Long? = 0, + var closedAt: Long? = null, + var state: TaskState = TaskState.IN_PROGRESS, + var project: SimpleProjectModel, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModelAssembler.kt new file mode 100644 index 0000000000..6ebf859688 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/task/TaskWithProjectModelAssembler.kt @@ -0,0 +1,49 @@ +package io.tolgee.hateoas.task + +import io.tolgee.api.v2.controllers.TaskController +import io.tolgee.dtos.cacheable.LanguageDto +import io.tolgee.hateoas.language.LanguageModelAssembler +import io.tolgee.hateoas.project.SimpleProjectModelAssembler +import io.tolgee.hateoas.userAccount.SimpleUserAccountModelAssembler +import io.tolgee.model.views.TaskWithScopeView +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class TaskWithProjectModelAssembler( + private val simpleUserAccountModelAssembler: SimpleUserAccountModelAssembler, + private val languageModelAssembler: LanguageModelAssembler, + private val simpleProjectModelAssembler: SimpleProjectModelAssembler, +) : RepresentationModelAssemblerSupport( + TaskController::class.java, + TaskWithProjectModel::class.java, + ) { + override fun toModel(entity: TaskWithScopeView): TaskWithProjectModel { + return TaskWithProjectModel( + number = entity.number, + name = entity.name, + description = entity.description, + type = entity.type, + language = + entity.language.let { + languageModelAssembler.toModel( + LanguageDto.fromEntity( + it, + entity.project.baseLanguage?.id, + ), + ) + }, + dueDate = entity.dueDate?.time, + assignees = entity.assignees.map { simpleUserAccountModelAssembler.toModel(it) }.toMutableSet(), + author = entity.author?.let { simpleUserAccountModelAssembler.toModel(it) }, + createdAt = entity.createdAt?.time, + closedAt = entity.closedAt?.time, + totalItems = entity.totalItems, + doneItems = entity.doneItems, + baseWordCount = entity.baseWordCount, + baseCharacterCount = entity.baseCharacterCount, + state = entity.state, + project = entity.project.let { simpleProjectModelAssembler.toModel(it) }, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModel.kt new file mode 100644 index 0000000000..804b5a9076 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModel.kt @@ -0,0 +1,13 @@ +package io.tolgee.hateoas.translations + +import io.tolgee.model.enums.TaskType +import org.springframework.hateoas.RepresentationModel + +open class KeyTaskViewModel( + val number: Long, + val languageId: Long, + val languageTag: String, + val done: Boolean, + val userAssigned: Boolean, + val type: TaskType, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModelAssembler.kt new file mode 100644 index 0000000000..29e4ab4cdb --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyTaskViewModelAssembler.kt @@ -0,0 +1,24 @@ +package io.tolgee.hateoas.translations + +import io.tolgee.api.v2.controllers.translation.TranslationsController +import io.tolgee.model.views.KeyTaskView +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class KeyTaskViewModelAssembler : + RepresentationModelAssemblerSupport( + TranslationsController::class.java, + KeyTaskViewModel::class.java, + ) { + override fun toModel(view: KeyTaskView): KeyTaskViewModel { + return KeyTaskViewModel( + number = view.number, + languageId = view.languageId, + languageTag = view.languageTag, + done = view.done, + userAssigned = view.userAssigned, + type = view.type, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt index 88baad4f9f..de28e152d9 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt @@ -45,4 +45,6 @@ open class KeyWithTranslationsModel( """, ) val translations: Map, + @Schema(description = "Tasks related to this key") + val tasks: List?, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt index 271695260d..8d16645371 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt @@ -12,6 +12,7 @@ class KeyWithTranslationsModelAssembler( private val translationViewModelAssembler: TranslationViewModelAssembler, private val tagModelAssembler: TagModelAssembler, private val screenshotModelAssembler: ScreenshotModelAssembler, + private val translationTaskViewModelAssembler: KeyTaskViewModelAssembler, ) : RepresentationModelAssemblerSupport( TranslationsController::class.java, KeyWithTranslationsModel::class.java, @@ -36,5 +37,9 @@ class KeyWithTranslationsModelAssembler( view.screenshots?.map { screenshotModelAssembler.toModel(it) }, + tasks = + view.tasks?.map { + translationTaskViewModelAssembler.toModel(it) + }, ) } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationViewModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationViewModelAssembler.kt index fbd2c4763d..2aa50bac41 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationViewModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationViewModelAssembler.kt @@ -6,7 +6,7 @@ import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSuppor import org.springframework.stereotype.Component @Component -class TranslationViewModelAssembler : RepresentationModelAssemblerSupport( +class TranslationViewModelAssembler() : RepresentationModelAssemblerSupport( TranslationsController::class.java, TranslationViewModel::class.java, ) { diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt index ecf53b02e4..5e0cb6f818 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModel.kt @@ -2,7 +2,9 @@ package io.tolgee.hateoas.userAccount import io.tolgee.dtos.Avatar import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation +@Relation(collectionRelation = "users", itemRelation = "user") data class SimpleUserAccountModel( val id: Long, val username: String, diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerAssigneesTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerAssigneesTest.kt new file mode 100644 index 0000000000..be4f8da7c4 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerAssigneesTest.kt @@ -0,0 +1,119 @@ +package io.tolgee.api.v2.controllers.task + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.TaskTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.enums.TaskType +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import net.javacrumbs.jsonunit.core.internal.Node.JsonMap +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TaskControllerAssigneesTest : ProjectAuthControllerTest("/v2/projects/") { + lateinit var testData: TaskTestData + + @BeforeEach + fun setup() { + testData = TaskTestData() + projectSupplier = { testData.projectBuilder.self } + testDataService.saveTestData(testData.root) + userAccount = testData.user + } + + @Test + @ProjectJWTAuthTestMethod + fun `properly includes user with view rights`() { + performProjectAuthGet( + "tasks/possible-assignees?filterMinimalScope=TRANSLATIONS_VIEW", + ).andIsOk.andAssertThatJson { + node("_embedded.users").isArray.anySatisfy { + (it as JsonMap).get("username").assert.isEqualTo("Project view scope user (en)") + } + node("_embedded.users").isArray.anySatisfy { + (it as JsonMap).get("username").assert.isEqualTo("Project view role user (en)") + } + node("_embedded.users").isArray.anySatisfy { + (it as JsonMap).get("username").assert.isEqualTo("Organization member") + } + node("_embedded.users").isArray.anySatisfy { + (it as JsonMap).get("username").assert.isEqualTo("Organization owner") + } + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `properly excludes user with view rights`() { + performProjectAuthGet( + "tasks/possible-assignees?filterMinimalScope=TRANSLATIONS_EDIT", + ).andIsOk.andAssertThatJson { + node("_embedded.users").isArray.allSatisfy { + (it as JsonMap).get("username").assert.isNotEqualTo("Project view scope user (en)") + } + node("_embedded.users").isArray.allSatisfy { + (it as JsonMap).get("username").assert.isNotEqualTo("Project view role user (en)") + } + node("_embedded.users").isArray.allSatisfy { + (it as JsonMap).get("username").assert.isNotEqualTo("Organization member") + } + node("_embedded.users").isArray.anySatisfy { + (it as JsonMap).get("username").assert.isEqualTo("Organization owner") + } + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `properly excludes users without view rights for english`() { + performProjectAuthGet( + "tasks/possible-assignees?filterMinimalScope=TRANSLATIONS_VIEW&filterViewLanguageId=${testData.czechLanguage.id}", + ).andIsOk.andAssertThatJson { + node("_embedded.users").isArray.allSatisfy { + (it as JsonMap).get("username").assert.isNotEqualTo("Project view scope user (en)") + } + node("_embedded.users").isArray.allSatisfy { + (it as JsonMap).get("username").assert.isNotEqualTo("Project view role user (en)") + } + node("_embedded.users").isArray.anySatisfy { + (it as JsonMap).get("username").assert.isEqualTo("Organization member") + } + node("_embedded.users").isArray.anySatisfy { + (it as JsonMap).get("username").assert.isEqualTo("Organization owner") + } + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `user tasks`() { + performAuthGet( + "/v2/user-tasks", + ).andIsOk.andAssertThatJson { + node("page").node("totalElements").isEqualTo(2) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `user tasks filter by assignee`() { + performAuthGet( + "/v2/user-tasks?filterAssignee=${testData.orgMember.self.id}", + ).andIsOk.andAssertThatJson { + node("page").node("totalElements").isEqualTo(1) + node("_embedded.tasks[0].name").isEqualTo("Review task") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `user tasks filter by type`() { + performAuthGet( + "/v2/user-tasks?filterType=${TaskType.TRANSLATE}", + ).andIsOk.andAssertThatJson { + node("page").node("totalElements").isEqualTo(1) + node("_embedded.tasks[0].name").isEqualTo("Translate task") + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt new file mode 100644 index 0000000000..2bce6bbedf --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt @@ -0,0 +1,101 @@ +package io.tolgee.api.v2.controllers.task + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.TaskTestData +import io.tolgee.dtos.request.task.UpdateTaskKeyRequest +import io.tolgee.dtos.request.task.UpdateTaskKeysRequest +import io.tolgee.dtos.request.task.UpdateTaskRequest +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") { + lateinit var testData: TaskTestData + + @BeforeEach + fun setup() { + testData = TaskTestData() + projectSupplier = { testData.projectBuilder.self } + testDataService.saveTestData(testData.root) + userAccount = testData.user + } + + @Test + @ProjectJWTAuthTestMethod + fun `sees only translate tasks in user tasks`() { + // is assigned to translate task + userAccount = testData.projectUser.self + + performAuthGet( + "/v2/user-tasks", + ).andIsOk.andAssertThatJson { + node("page.totalElements").isEqualTo(1) + node("_embedded.tasks[0].name").isEqualTo("Translate task") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `can access translate task's data`() { + userAccount = testData.projectUser.self + + performProjectAuthGet("tasks/${testData.translateTask.self.number}").andIsOk + performProjectAuthGet("tasks/${testData.translateTask.self.number}/per-user-report").andIsOk + performProjectAuthGet("tasks/${testData.translateTask.self.number}/csv-report").andIsOk + performProjectAuthGet("tasks/${testData.translateTask.self.number}/keys").andIsOk + } + + @Test + @ProjectJWTAuthTestMethod + fun `can do necessary translate task's operations`() { + userAccount = testData.projectUser.self + + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}/keys/${testData.keysInTask.first().self.id}", + UpdateTaskKeyRequest(done = true), + ).andIsOk + performProjectAuthPost("tasks/${testData.translateTask.self.number}/finish").andIsOk + } + + @Test + @ProjectJWTAuthTestMethod + fun `can't do advanced translate task's operations`() { + userAccount = testData.projectUser.self + + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}/keys", + UpdateTaskKeysRequest(addKeys = mutableSetOf(testData.keysOutOfTask.first().self.id)), + ).andIsForbidden + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}", + UpdateTaskRequest(name = "Test"), + ).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `can't access review task's data`() { + userAccount = testData.projectUser.self + + performProjectAuthGet("tasks/${testData.reviewTask.self.number}").andIsForbidden + performProjectAuthGet("tasks/${testData.reviewTask.self.number}/per-user-report").andIsForbidden + performProjectAuthGet("tasks/${testData.reviewTask.self.number}/csv-report").andIsForbidden + performProjectAuthGet("tasks/${testData.reviewTask.self.number}/keys").andIsForbidden + performProjectAuthPut( + "tasks/${testData.reviewTask.self.number}/keys/${testData.keysInTask.first().self.id}", + UpdateTaskKeyRequest(done = true), + ).andIsForbidden + performProjectAuthPost("tasks/${testData.reviewTask.self.number}/finish").andIsForbidden + performProjectAuthPut( + "tasks/${testData.reviewTask.self.number}/keys", + UpdateTaskKeysRequest(addKeys = mutableSetOf(testData.keysOutOfTask.first().self.id)), + ).andIsForbidden + performProjectAuthPut( + "tasks/${testData.reviewTask.self.number}", + UpdateTaskRequest(name = "Test"), + ).andIsForbidden + } +} 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 new file mode 100644 index 0000000000..0ea76d2802 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerTest.kt @@ -0,0 +1,338 @@ +package io.tolgee.api.v2.controllers.task + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.constants.Message +import io.tolgee.development.testDataBuilder.data.TaskTestData +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.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { + lateinit var testData: TaskTestData + + @BeforeEach + fun setup() { + testData = TaskTestData() + projectSupplier = { testData.projectBuilder.self } + testDataService.saveTestData(testData.root) + userAccount = testData.user + } + + @Test + @ProjectJWTAuthTestMethod + fun `task exists`() { + performProjectAuthGet("tasks").andAssertThatJson { + node("_embedded.tasks") { + node("[0].number").isEqualTo(1) + node("[0].name").isEqualTo("Translate task") + node("[1].number").isEqualTo(2) + node("[1].name").isEqualTo("Review task") + } + node("page.totalElements").isNumber.isEqualTo(BigDecimal(2)) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `gets task detail`() { + performProjectAuthGet( + "tasks/${testData.translateTask.self.number}", + ).andAssertThatJson { + node("name").isEqualTo("Translate task") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `creates new task`() { + val keys = testData.keysOutOfTask.map { it.self.id }.toMutableSet() + performProjectAuthPost( + "tasks", + CreateTaskRequest( + name = "Another task", + description = "...", + type = TaskType.TRANSLATE, + languageId = testData.englishLanguage.id, + assignees = + mutableSetOf( + testData.orgMember.self.id, + ), + keys = keys, + ), + ).andAssertThatJson { + node("number").isNumber + node("name").isEqualTo("Another task") + node("assignees[0].name").isEqualTo(testData.orgMember.self.name) + node("language.tag").isEqualTo(testData.englishLanguage.tag) + node("totalItems").isEqualTo(keys.size) + } + + performProjectAuthGet("tasks").andAssertThatJson { + node("page.totalElements").isNumber.isEqualTo(BigDecimal(3)) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `creates multiple new tasks`() { + val keys = testData.keysOutOfTask.map { it.self.id }.toMutableSet() + performProjectAuthPost( + "tasks/create-multiple", + CreateMultipleTasksRequest( + mutableSetOf( + CreateTaskRequest( + name = "Another task", + description = "...", + type = TaskType.TRANSLATE, + languageId = testData.englishLanguage.id, + assignees = + mutableSetOf( + testData.orgMember.self.id, + ), + keys = keys, + ), + CreateTaskRequest( + name = "Another task", + description = "...", + type = TaskType.TRANSLATE, + languageId = testData.czechLanguage.id, + assignees = + mutableSetOf( + testData.orgMember.self.id, + ), + keys = keys, + ), + ), + ), + ).andIsOk + + performProjectAuthGet("tasks").andAssertThatJson { + node("page.totalElements").isNumber.isEqualTo(BigDecimal(4)) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `calculates stats for task`() { + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}/keys/${testData.keysInTask.first().self.id}", + UpdateTaskKeyRequest(done = true), + ).andIsOk + + performProjectAuthGet( + "tasks/${testData.translateTask.self.number}", + ).andIsOk.andAssertThatJson { + node("totalItems").isEqualTo(2) + node("doneItems").isEqualTo(1) + } + + performProjectAuthGet( + "tasks/${testData.translateTask.self.number}/per-user-report", + ).andIsOk.andAssertThatJson { + node("[0]").node("doneItems").isEqualTo(1) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `fails to create task with assignee from different project`() { + performProjectAuthPost( + "tasks", + CreateTaskRequest( + name = "Another task", + description = "...", + type = TaskType.TRANSLATE, + languageId = testData.englishLanguage.id, + assignees = + mutableSetOf( + testData.unrelatedUser.self.id, + ), + keys = mutableSetOf(), + ), + ).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo(Message.USER_HAS_NO_PROJECT_ACCESS) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `fails to create task with language from different project`() { + performProjectAuthPost( + "tasks", + CreateTaskRequest( + name = "Another task", + description = "...", + type = TaskType.TRANSLATE, + languageId = testData.unrelatedEnglish.self.id, + assignees = mutableSetOf(), + keys = mutableSetOf(), + ), + ).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo(Message.LANGUAGE_NOT_FROM_PROJECT) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `updates existing task`() { + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}", + UpdateTaskRequest( + name = "Updated task", + description = "updated description", + assignees = mutableSetOf(), + ), + ).andIsOk.andAssertThatJson { + node("number").isEqualTo(testData.translateTask.self.number) + node("name").isEqualTo("Updated task") + node("description").isEqualTo("updated description") + node("assignees").isEqualTo(mutableListOf()) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `fails when updating assignees to non-members`() { + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}", + UpdateTaskRequest( + assignees = mutableSetOf(testData.unrelatedUser.self.id), + ), + ).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo(Message.USER_HAS_NO_PROJECT_ACCESS) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `can add keys`() { + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}/keys", + UpdateTaskKeysRequest( + addKeys = testData.keysOutOfTask.map { it.self.id }.toMutableSet(), + ), + ).andIsOk + + performProjectAuthGet( + "tasks/${testData.translateTask.self.number}", + ).andIsOk.andAssertThatJson { + // Calculate expected keys set + val expectedItems = (testData.keysInTask union testData.keysOutOfTask).size + + node("totalItems").isEqualTo(expectedItems) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `can remove keys`() { + val allKeys = testData.keysInTask.map { it.self.id }.toMutableSet() + val keysToRemove = mutableSetOf(allKeys.first()) + val remainingKeys = allKeys.subtract(keysToRemove) + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}/keys", + UpdateTaskKeysRequest( + removeKeys = keysToRemove, + ), + ).andIsOk + + performProjectAuthGet( + "tasks/${testData.translateTask.self.number}", + ).andIsOk.andAssertThatJson { + node("totalItems").isEqualTo(remainingKeys.size) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `calculates stats`() { + val allKeys = testData.keysOutOfTask.map { it.self.id }.toMutableSet() + performProjectAuthPost( + "tasks/calculate-scope", + CalculateScopeRequest( + language = testData.englishLanguage.id, + type = TaskType.TRANSLATE, + keys = allKeys, + ), + ).andIsOk.andAssertThatJson { + node("keyCount").isEqualTo(allKeys.size) + node("wordCount").isEqualTo(4) + node("characterCount").isEqualTo(26) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `doesn't include keys which are in different task of same type`() { + val allKeys = testData.keysInTask.map { it.self.id }.toMutableSet() + performProjectAuthPost( + "tasks/calculate-scope", + CalculateScopeRequest( + language = testData.englishLanguage.id, + type = TaskType.TRANSLATE, + keys = allKeys, + ), + ).andIsOk.andAssertThatJson { + node("keyCount").isEqualTo(0) + node("wordCount").isEqualTo(0) + node("characterCount").isEqualTo(0) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `includes keys included in task of different type`() { + val allKeys = testData.keysInTask.map { it.self.id }.toMutableSet() + performProjectAuthPost( + "tasks/calculate-scope", + CalculateScopeRequest( + language = testData.englishLanguage.id, + type = TaskType.REVIEW, + keys = allKeys, + ), + ).andIsOk.andAssertThatJson { + node("keyCount").isEqualTo(allKeys.size) + node("wordCount").isEqualTo(4) + node("characterCount").isEqualTo(26) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `project tasks`() { + performProjectAuthGet( + "tasks", + ).andIsOk.andAssertThatJson { + node("page").node("totalElements").isEqualTo(2) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `project tasks filter by assignee`() { + performProjectAuthGet( + "tasks?filterAssignee=${testData.orgMember.self.id}", + ).andIsOk.andAssertThatJson { + node("page").node("totalElements").isEqualTo(1) + node("_embedded.tasks[0].name").isEqualTo("Review task") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `project tasks filter by type`() { + performProjectAuthGet( + "tasks?filterType=${TaskType.TRANSLATE}", + ).andIsOk.andAssertThatJson { + node("page").node("totalElements").isEqualTo(1) + node("_embedded.tasks[0].name").isEqualTo("Translate task") + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt index 4e59309d6a..71ad6f8a72 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerInvitationTest.kt @@ -83,9 +83,11 @@ class ProjectsControllerInvitationTest : ProjectAuthControllerTest("/v2/projects type = ProjectPermissionType.TRANSLATE languages = setOf(getLang("en")) }.andIsOk - val invitation = invitationTestUtil.getInvitation(result) - invitation.permission?.translateLanguages!!.map { it.tag }.assert.contains("en") // stores - invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + executeInNewTransaction { + val invitation = invitationTestUtil.getInvitation(result) + invitation.permission?.translateLanguages!!.map { it.tag }.assert.contains("en") // stores + invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + } } @Test @@ -97,9 +99,11 @@ class ProjectsControllerInvitationTest : ProjectAuthControllerTest("/v2/projects translateLanguages = setOf(getLang("en")) stateChangeLanguages = setOf(getLang("en")) }.andIsOk - val invitation = invitationTestUtil.getInvitation(result) - invitation.permission?.stateChangeLanguages!!.map { it.tag }.assert.contains("en") // stores - invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + executeInNewTransaction { + val invitation = invitationTestUtil.getInvitation(result) + invitation.permission?.stateChangeLanguages!!.map { it.tag }.assert.contains("en") // stores + invitation.permission?.viewLanguages!!.map { it.tag }.assert.contains() // ads also to view + } } @Test diff --git a/backend/app/src/test/kotlin/io/tolgee/repository/ProjectRepositoryTest.kt b/backend/app/src/test/kotlin/io/tolgee/repository/ProjectRepositoryTest.kt index 1d9ce53b4d..26a0ec7e93 100644 --- a/backend/app/src/test/kotlin/io/tolgee/repository/ProjectRepositoryTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/repository/ProjectRepositoryTest.kt @@ -1,6 +1,7 @@ package io.tolgee.repository import io.tolgee.development.DbPopulatorReal +import io.tolgee.dtos.request.project.ProjectFilters import io.tolgee.fixtures.generateUniqueString import io.tolgee.model.OrganizationRole import io.tolgee.model.Project @@ -52,7 +53,12 @@ class ProjectRepositoryTest { fun findAllPermittedPaged() { val users = dbPopulatorReal.createUsersAndOrganizations() dbPopulatorReal.createBase("No org project", users[3].username) - val result = projectRepository.findAllPermitted(users[3].id, PageRequest.of(0, 20, Sort.by(Sort.Order.asc("id")))) + val result = + projectRepository.findAllPermitted( + users[3].id, + PageRequest.of(0, 20, Sort.by(Sort.Order.asc("id"))), + filters = ProjectFilters(), + ) assertThat(result).hasSize(10) assertThat(result.content[0].organizationOwner?.name).isNotNull assertThat(result.content[8].organizationOwner?.slug).isNotNull diff --git a/backend/app/src/test/kotlin/io/tolgee/repository/UserAccountRepositoryTest.kt b/backend/app/src/test/kotlin/io/tolgee/repository/UserAccountRepositoryTest.kt index 9848fe0031..a00c95d443 100644 --- a/backend/app/src/test/kotlin/io/tolgee/repository/UserAccountRepositoryTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/repository/UserAccountRepositoryTest.kt @@ -2,6 +2,7 @@ package io.tolgee.repository import io.tolgee.AbstractSpringTest import io.tolgee.development.DbPopulatorReal +import io.tolgee.dtos.request.task.UserAccountFilters import io.tolgee.model.views.UserAccountWithOrganizationRoleView import io.tolgee.testing.assertions.Assertions.assertThat import org.junit.jupiter.api.Test @@ -47,7 +48,13 @@ class UserAccountRepositoryTest : AbstractSpringTest() { permissionService.grantFullAccessToProject(franta, repo) - val returned = userAccountRepository.getAllInProject(repo.id, PageRequest.of(0, 20), "franta") + val returned = + userAccountRepository.getAllInProject( + repo.id, + PageRequest.of(0, 20), + "franta", + filters = UserAccountFilters(), + ) assertThat(returned.content).hasSize(1) } } diff --git a/backend/data/build.gradle b/backend/data/build.gradle index 9acbebf68b..e29042cc06 100644 --- a/backend/data/build.gradle +++ b/backend/data/build.gradle @@ -205,6 +205,12 @@ dependencies { implementation libs.slackApiClient implementation libs.slackApiModelKotlinExtension implementation libs.slackApiClientKotlinExtension + + /** + * Excel file generation + */ + implementation 'org.apache.poi:poi-ooxml:5.2.3' + implementation 'org.apache.commons:commons-collections4:4.4' } test { diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt b/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt index 849d17fdb3..0a4c92df6a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt @@ -65,4 +65,11 @@ enum class ActivityType( WEBHOOK_CONFIG_UPDATE, WEBHOOK_CONFIG_DELETE, COMPLEX_TAG_OPERATION(onlyCountsInList = true), + TASKS_CREATE, + TASK_CREATE, + TASK_UPDATE, + TASK_KEYS_UPDATE, + TASK_FINISH, + TASK_CLOSE, + TASK_REOPEN, } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/task/TaskReportHelper.kt b/backend/data/src/main/kotlin/io/tolgee/component/task/TaskReportHelper.kt new file mode 100644 index 0000000000..773b49a1e2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/task/TaskReportHelper.kt @@ -0,0 +1,128 @@ +package io.tolgee.component.task + +import io.tolgee.model.Language +import io.tolgee.model.UserAccount +import io.tolgee.model.views.TaskPerUserReportView +import io.tolgee.model.views.TaskWithScopeView +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import java.text.DateFormat +import java.util.* + +class TaskReportHelper( + private val task: TaskWithScopeView, + private val report: List, +) { + fun formatDate(date: Date): String { + return DateFormat.getDateInstance(DateFormat.DEFAULT, Locale.ENGLISH).format(date) + } + + fun formatLanguage(language: Language?): String { + if (language == null) { + return "" + } + val result = StringBuilder(language.name) + if (language.name != language.tag) { + result.append(" (${language.tag})") + } + return result.toString() + } + + fun formatUserName(user: UserAccount): String { + val result = StringBuilder(user.name) + if (user.name != user.username) { + result.append(" (${user.username})") + } + return result.toString() + } + + fun capitalize(text: String): String { + return text.lowercase().replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase( + Locale.getDefault(), + ) + } else { + it.toString() + } + } + } + + fun generateExcelReport(): XSSFWorkbook { + val workbook = XSSFWorkbook() + val sheet = workbook.createSheet("Task Report") + + sheet.createRow(0).let { + it.createCell(0).setCellValue("Task name") + it.createCell(1).setCellValue(task.name) + } + + sheet.createRow(1).let { + it.createCell(0).setCellValue("Task description") + it.createCell(1).setCellValue(task.description) + } + + sheet.createRow(2).let { + it.createCell(0).setCellValue("Type") + it.createCell(1).setCellValue(capitalize(task.type.toString())) + } + + sheet.createRow(3).let { + it.createCell(0).setCellValue("Project name") + it.createCell(1).setCellValue(task.project.name) + } + + sheet.createRow(4).let { + it.createCell(0).setCellValue("Base language") + it.createCell(1).setCellValue(formatLanguage(task.project.baseLanguage)) + } + + sheet.createRow(5).let { + it.createCell(0).setCellValue("Target language") + it.createCell(1).setCellValue(formatLanguage(task.language)) + } + + sheet.createRow(6).let { + it.createCell(0).setCellValue("Created at") + task.createdAt?.let { createdAt -> + it.createCell(1).setCellValue(formatDate(createdAt)) + } + } + + sheet.createRow(7).let { + it.createCell(0).setCellValue("Created by") + it.createCell(1).setCellValue(formatUserName(task.author)) + } + + sheet.createRow(8).let { + it.createCell(0).setCellValue("Due date") + it.createCell(1).setCellValue(task.dueDate?.let { formatDate(it) }) + } + + sheet.createRow(10).let { + it.createCell(1).setCellValue("Keys") + it.createCell(2).setCellValue("Words") + it.createCell(3).setCellValue("Characters") + } + + val dataRow = sheet.createRow(11) + dataRow.createCell(0).setCellValue("Total to translate") + dataRow.createCell(1).setCellValue(task.totalItems.toDouble()) + dataRow.createCell(2).setCellValue(task.baseWordCount.toDouble()) + dataRow.createCell(3).setCellValue(task.baseCharacterCount.toDouble()) + + report.forEachIndexed { index, taskReport -> + val row = sheet.createRow(12 + index) + row.createCell(0).setCellValue(formatUserName(taskReport.user)) + row.createCell(1).setCellValue(taskReport.doneItems.toDouble()) + row.createCell(2).setCellValue(taskReport.baseWordCount.toDouble()) + row.createCell(3).setCellValue(taskReport.baseCharacterCount.toDouble()) + } + + // Auto-size all columns for better readability + for (i in 0..3) { + sheet.autoSizeColumn(i) + } + + return workbook + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index f95946ba29..f3e5e1691c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -239,6 +239,9 @@ enum class Message { PLAN_AUTO_ASSIGNMENT_ONLY_FOR_FREE_PLANS, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_PRIVATE_PLANS, PLAN_AUTO_ASSIGNMENT_ORGANIZATION_IDS_NOT_IN_FOR_ORGANIZATION_IDS, + TASK_NOT_FOUND, + TASK_NOT_FINISHED, + TASK_NOT_OPEN, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 5425b46918..e28bc46a2f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -225,6 +225,8 @@ class TestDataService( saveAutomations(builder) saveImportSettings(builder) saveBatchJobs(builder) + saveTasks(builder) + saveTaskKeys(builder) } private fun saveImportSettings(builder: ProjectBuilder) { @@ -474,6 +476,18 @@ class TestDataService( } } + private fun saveTasks(builder: ProjectBuilder) { + builder.data.tasks.forEach { + entityManager.persist(it.self) + } + } + + private fun saveTaskKeys(builder: ProjectBuilder) { + builder.data.taskKeys.forEach { + entityManager.persist(it.self) + } + } + private fun saveChunkExecutions(batchJobBuilder: BatchJobBuilder) { batchJobBuilder.data.chunkExecutions.forEach { entityManager.persist(it.self) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/KeyBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/KeyBuilder.kt index 35fcff1653..c4604c6119 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/KeyBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/KeyBuilder.kt @@ -79,8 +79,8 @@ class KeyBuilder( fun addTranslation( languageTag: String, text: String?, - ) { - addTranslation { + ): TranslationBuilder { + return addTranslation { this.language = projectBuilder.getLanguageByTag(languageTag)!!.self this.text = text } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt index 0679f494fc..f77559cd45 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt @@ -15,6 +15,8 @@ import io.tolgee.model.key.screenshotReference.KeyScreenshotReference import io.tolgee.model.keyBigMeta.KeysDistance import io.tolgee.model.mtServiceConfig.MtServiceConfig import io.tolgee.model.slackIntegration.SlackConfig +import io.tolgee.model.task.Task +import io.tolgee.model.task.TaskKey import io.tolgee.model.translation.Translation import io.tolgee.model.webhook.WebhookConfig import org.springframework.core.io.ClassPathResource @@ -55,6 +57,8 @@ class ProjectBuilder( var importSettings: ImportSettings? = null var slackConfigs = mutableListOf() val batchJobs: MutableList = mutableListOf() + val tasks = mutableListOf() + val taskKeys = mutableListOf() } var data = DATA() @@ -69,6 +73,10 @@ class ProjectBuilder( fun addKey(ft: FT) = addOperation(data.keys, ft) + fun addTask(ft: FT) = addOperation(data.tasks, ft) + + fun addTaskKey(ft: FT) = addOperation(data.taskKeys, ft) + fun addKey( namespace: String? = null, keyName: String, diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskBuilder.kt new file mode 100644 index 0000000000..781c9c43af --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskBuilder.kt @@ -0,0 +1,13 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.development.testDataBuilder.EntityDataBuilder +import io.tolgee.model.task.Task + +class TaskBuilder( + val projectBuilder: ProjectBuilder, +) : EntityDataBuilder { + override var self: Task = + Task().apply { + this.project = projectBuilder.self + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskKeyBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskKeyBuilder.kt new file mode 100644 index 0000000000..5caa1d87a7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TaskKeyBuilder.kt @@ -0,0 +1,11 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.development.testDataBuilder.EntityDataBuilder +import io.tolgee.model.task.TaskKey + +class TaskKeyBuilder( + val projectBuilder: ProjectBuilder, +) : EntityDataBuilder { + override var self: TaskKey = + TaskKey() +} 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 new file mode 100644 index 0000000000..0ee897afe0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TaskTestData.kt @@ -0,0 +1,198 @@ +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 + +class TaskTestData : BaseTestData("tagsTestUser", "tagsTestProject") { + var projectUser: UserAccountBuilder + var orgAdmin: UserAccountBuilder + var orgMember: UserAccountBuilder + var projectViewScopeUser: UserAccountBuilder + var projectViewRoleUser: UserAccountBuilder + var translateTask: TaskBuilder + var reviewTask: TaskBuilder + var relatedProject: ProjectBuilder + var keysInTask: MutableSet = mutableSetOf() + var keysOutOfTask: MutableSet = mutableSetOf() + lateinit var czechLanguage: Language + + var unrelatedOrg = OrganizationBuilder(root) + var unrelatedProject: ProjectBuilder + var unrelatedUser: UserAccountBuilder + var unrelatedEnglish: LanguageBuilder + + init { + projectUser = UserAccountBuilder(root) + + projectUser.self.apply { + username = "Project user" + } + root.data.userAccounts.add(projectUser) + + orgMember = UserAccountBuilder(root) + + orgMember.self.apply { + username = "Organization member" + } + + orgAdmin = UserAccountBuilder(root) + + orgAdmin.self.apply { + username = "Organization owner" + } + + projectViewScopeUser = UserAccountBuilder(root) + + projectViewScopeUser.self.apply { + username = "Project view scope user (en)" + } + + projectViewRoleUser = UserAccountBuilder(root) + + projectViewRoleUser.self.apply { + username = "Project view role user (en)" + } + + userAccountBuilder.defaultOrganizationBuilder.apply { + addRole { + user = orgMember.self + type = OrganizationRoleType.MEMBER + } + + addRole { + user = orgAdmin.self + type = OrganizationRoleType.OWNER + } + } + + projectBuilder.apply { + relatedProject = this + + addLanguage { + name = "Czech" + tag = "cs" + originalName = "Čeština" + czechLanguage = this + } + + addPermission { + user = projectUser.self + type = ProjectPermissionType.EDIT + } + + addPermission { + user = projectViewScopeUser.self + scopes = arrayOf(Scope.TRANSLATIONS_VIEW) + viewLanguages = mutableSetOf(englishLanguage) + } + + addPermission { + user = projectViewRoleUser.self + type = ProjectPermissionType.VIEW + viewLanguages = mutableSetOf(englishLanguage) + } + + (0 until 2).forEach { + keysInTask.add( + addKey(null, "key $it").apply { + addTranslation("en", "Translation $it") + addTranslation("cs", "Překlad $it") + }, + ) + } + + (2 until 4).forEach { + keysOutOfTask.add( + addKey(null, "key $it").apply { + addTranslation("en", "Translation $it") + }, + ) + } + + translateTask = + addTask { + number = 1 + name = "Translate task" + type = TaskType.TRANSLATE + assignees = + mutableSetOf( + projectUser.self, + user, + ) + project = projectBuilder.self + language = englishLanguage + author = projectUser.self + } + + keysInTask.forEach { it -> + addTaskKey { + task = translateTask.self + key = it.self + } + } + + reviewTask = + addTask { + number = 2 + name = "Review task" + type = TaskType.REVIEW + assignees = + mutableSetOf( + orgMember.self, + user, + ) + project = projectBuilder.self + language = czechLanguage + author = projectUser.self + } + + keysInTask.forEach { it -> + addTaskKey { + task = reviewTask.self + key = it.self + } + } + } + + unrelatedOrg.self.apply { + name = "Unrelated org" + } + + unrelatedProject = ProjectBuilder(unrelatedOrg.self, root) + + unrelatedProject.apply { + unrelatedEnglish = addEnglish() + } + + unrelatedProject.self.apply { + name = "Unrelated project" + languages = mutableSetOf(unrelatedEnglish.self) + baseLanguage = unrelatedEnglish.self + } + + unrelatedUser = UserAccountBuilder(root) + + unrelatedUser.self.apply { + username = "Unrelated user" + } + + unrelatedProject.apply { + addPermission { + user = unrelatedUser.self + type = ProjectPermissionType.EDIT + } + } + + root.data.userAccounts.add(orgMember) + root.data.userAccounts.add(orgAdmin) + root.data.organizations.add(unrelatedOrg) + root.data.projects.add(unrelatedProject) + root.data.userAccounts.add(unrelatedUser) + root.data.userAccounts.add(projectViewScopeUser) + root.data.userAccounts.add(projectViewRoleUser) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt new file mode 100644 index 0000000000..e8b6eed89a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt @@ -0,0 +1,15 @@ +package io.tolgee.dtos.request.language + +import io.swagger.v3.oas.annotations.Parameter + +open class LanguageFilters { + @field:Parameter( + description = """Filter languages by id""", + ) + var filterId: List? = null + + @field:Parameter( + description = """Filter languages without id""", + ) + var filterNotId: List? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectFilters.kt new file mode 100644 index 0000000000..17b45423d4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectFilters.kt @@ -0,0 +1,15 @@ +package io.tolgee.dtos.request.project + +import io.swagger.v3.oas.annotations.Parameter + +open class ProjectFilters { + @field:Parameter( + description = """Filter projects by id""", + ) + var filterId: List? = null + + @field:Parameter( + description = """Filter projects without id""", + ) + var filterNotId: List? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CalculateScopeRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CalculateScopeRequest.kt new file mode 100644 index 0000000000..24e7385935 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CalculateScopeRequest.kt @@ -0,0 +1,13 @@ +package io.tolgee.dtos.request.task + +import io.tolgee.model.enums.TaskType +import jakarta.validation.constraints.NotNull + +data class CalculateScopeRequest( + @field:NotNull + var language: Long, + @field:NotNull + var type: TaskType, + @field:NotNull + var keys: MutableSet? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateMultipleTasksRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateMultipleTasksRequest.kt new file mode 100644 index 0000000000..e7a9145cbc --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateMultipleTasksRequest.kt @@ -0,0 +1,5 @@ +package io.tolgee.dtos.request.task + +class CreateMultipleTasksRequest( + var tasks: MutableSet = mutableSetOf(), +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateTaskRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateTaskRequest.kt new file mode 100644 index 0000000000..69ab83bee3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/CreateTaskRequest.kt @@ -0,0 +1,34 @@ +package io.tolgee.dtos.request.task + +import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.model.enums.TaskType +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CreateTaskRequest( + @field:NotBlank + @field:Size(min = 3, max = 255) + var name: String = "", + @field:Size(min = 0, max = 2000) + var description: String = "", + @Enumerated(EnumType.STRING) + val type: TaskType, + @Schema( + description = "Due to date in epoch format (milliseconds).", + example = "1661172869000", + ) + var dueDate: Long? = null, + @Schema( + description = "Id of language, this task is attached to.", + example = "1", + ) + @field:NotNull + var languageId: Long? = null, + @field:NotNull + var assignees: MutableSet? = null, + @field:NotNull + var keys: MutableSet? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskFilters.kt new file mode 100644 index 0000000000..5dc437d6b3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskFilters.kt @@ -0,0 +1,63 @@ +package io.tolgee.dtos.request.task + +import io.swagger.v3.oas.annotations.Parameter +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType +import java.util.* + +open class TaskFilters { + @field:Parameter( + description = """Filter tasks by state""", + ) + var filterState: List? = null + + @field:Parameter( + description = """Filter tasks without state""", + ) + var filterNotState: List? = null + + @field:Parameter( + description = """Filter tasks by assignee""", + ) + var filterAssignee: List? = null + + @field:Parameter( + description = """Filter tasks by type""", + ) + var filterType: List? = null + + @field:Parameter( + description = """Filter tasks by id""", + ) + var filterId: List? = null + + @field:Parameter( + description = """Filter tasks without id""", + ) + var filterNotId: List? = null + + @field:Parameter( + description = """Filter tasks by project""", + ) + var filterProject: List? = null + + @field:Parameter( + description = """Filter tasks without project""", + ) + var filterNotProject: List? = null + + @field:Parameter( + description = """Filter tasks by language""", + ) + var filterLanguage: List? = null + + @field:Parameter( + description = """Filter tasks by key""", + ) + var filterKey: List? = null + + @field:Parameter( + description = """Exclude "done" tasks which are older than specified timestamp""", + ) + var filterDoneMinClosedAt: Long? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskKeysResponse.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskKeysResponse.kt new file mode 100644 index 0000000000..636ba05bce --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TaskKeysResponse.kt @@ -0,0 +1,5 @@ +package io.tolgee.dtos.request.task + +data class TaskKeysResponse( + val keys: List, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TranslationScopeFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TranslationScopeFilters.kt new file mode 100644 index 0000000000..2a5504657d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/TranslationScopeFilters.kt @@ -0,0 +1,12 @@ +package io.tolgee.dtos.request.task + +import io.tolgee.model.enums.TranslationState + +open class TranslationScopeFilters { + var filterState: List? = listOf() + var filterOutdated: Boolean? = false + + val filterStateOrdinal: List? get() { + return filterState?.map { it.ordinal } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyRequest.kt new file mode 100644 index 0000000000..522442a0e3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyRequest.kt @@ -0,0 +1,8 @@ +package io.tolgee.dtos.request.task + +import jakarta.validation.constraints.NotNull + +data class UpdateTaskKeyRequest( + @field:NotNull + var done: Boolean, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyResponse.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyResponse.kt new file mode 100644 index 0000000000..cf97c8f52a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeyResponse.kt @@ -0,0 +1,6 @@ +package io.tolgee.dtos.request.task + +data class UpdateTaskKeyResponse( + val done: Boolean, + val taskFinished: Boolean, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeysRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeysRequest.kt new file mode 100644 index 0000000000..8d05c68906 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskKeysRequest.kt @@ -0,0 +1,6 @@ +package io.tolgee.dtos.request.task + +data class UpdateTaskKeysRequest( + var addKeys: MutableSet? = null, + var removeKeys: MutableSet? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskRequest.kt new file mode 100644 index 0000000000..89439fad76 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/task/UpdateTaskRequest.kt @@ -0,0 +1,17 @@ +package io.tolgee.dtos.request.task + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +data class UpdateTaskRequest( + @field:Size(min = 3, max = 255) + var name: String? = null, + @field:Size(min = 0, max = 2000) + var description: String? = "", + @Schema( + description = "Due to date in epoch format (milliseconds).", + example = "1661172869000", + ) + var dueDate: Long? = -1, + var assignees: MutableSet? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/TranslationFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/TranslationFilters.kt index afc19c1228..eb4d5b8920 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/TranslationFilters.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/TranslationFilters.kt @@ -99,4 +99,9 @@ To filter default namespace, set to empty string. description = "Select only keys which were not successfully translated by batch job with provided id", ) var filterFailedKeysOfJob: Long? = null + + @field:Parameter( + description = "Select only keys which are in specified task", + ) + var filterTaskNumber: List? = null } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountFilters.kt new file mode 100644 index 0000000000..df76c7bb19 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountFilters.kt @@ -0,0 +1,15 @@ +package io.tolgee.dtos.request.task + +import io.swagger.v3.oas.annotations.Parameter + +open class UserAccountFilters { + @field:Parameter( + description = """Filter users by id""", + ) + var filterId: List? = null + + @field:Parameter( + description = """Filter users without id""", + ) + var filterNotId: List? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountPermissionsFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountPermissionsFilters.kt new file mode 100644 index 0000000000..e0b4af72a2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/userAccount/UserAccountPermissionsFilters.kt @@ -0,0 +1,44 @@ +package io.tolgee.dtos.request.userAccount + +import io.swagger.v3.oas.annotations.Parameter +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope + +open class UserAccountPermissionsFilters { + @field:Parameter( + description = """Filter users by id""", + ) + var filterId: List? = null + + @field:Parameter( + description = """Filter only users that have at least following scopes""", + ) + var filterMinimalScope: String? = null + + @field:Parameter( + description = """Filter only users that can view language""", + ) + var filterViewLanguageId: Long? = null + + @field:Parameter( + description = """Filter only users that can edit language""", + ) + var filterEditLanguageId: Long? = null + + @field:Parameter( + description = """Filter only users that can edit state of language""", + ) + var filterStateLanguageId: Long? = null + + val filterMinimalScopeExtended get(): String? { + return filterMinimalScope?.let { + "{${Scope.outer(Scope.valueOf(it)).joinToString(",")}}" + } + } + + val filterMinimalRole get(): List { + return filterMinimalScope?.let { + ProjectPermissionType.findByScope(Scope.valueOf(it)).map { it.toString() } + } ?: listOf() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt b/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt index 3c0cd8b330..954f9ad8a9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/EmailVerification.kt @@ -1,11 +1,6 @@ package io.tolgee.model -import jakarta.persistence.Entity -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.OneToOne -import jakarta.persistence.Table +import jakarta.persistence.* import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank @@ -21,7 +16,7 @@ data class EmailVerification( var newEmail: String? = null, ) : AuditModel() { @Suppress("JoinDeclarationAndAssignment") - @OneToOne(optional = false) + @OneToOne(optional = false, fetch = FetchType.LAZY) lateinit var userAccount: UserAccount constructor( diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Invitation.kt b/backend/data/src/main/kotlin/io/tolgee/model/Invitation.kt index b5765a136f..146d875b15 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Invitation.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Invitation.kt @@ -1,13 +1,6 @@ package io.tolgee.model -import jakarta.persistence.CascadeType -import jakarta.persistence.Entity -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.OneToOne -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint +import jakarta.persistence.* import jakarta.validation.constraints.NotBlank @Entity @@ -22,10 +15,10 @@ class Invitation( var id: Long? = null, var code: @NotBlank String, ) : AuditModel() { - @OneToOne(mappedBy = "invitation", cascade = [CascadeType.ALL]) + @OneToOne(mappedBy = "invitation", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, optional = true) var permission: Permission? = null - @OneToOne(mappedBy = "invitation", cascade = [CascadeType.ALL]) + @OneToOne(mappedBy = "invitation", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, optional = true) var organizationRole: OrganizationRole? = null constructor( diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Language.kt b/backend/data/src/main/kotlin/io/tolgee/model/Language.kt index 5a54d2ac6c..81c2db6ffa 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Language.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Language.kt @@ -7,6 +7,7 @@ import io.tolgee.activity.annotation.ActivityReturnsExistence import io.tolgee.dtos.request.LanguageRequest import io.tolgee.events.OnLanguagePrePersist import io.tolgee.model.mtServiceConfig.MtServiceConfig +import io.tolgee.model.task.Task import io.tolgee.model.translation.Translation import jakarta.persistence.Column import jakarta.persistence.Entity @@ -79,6 +80,9 @@ class Language : StandardAuditModel(), ILanguage, SoftDeletable { @OneToOne(mappedBy = "language", orphanRemoval = true, fetch = FetchType.LAZY) var stats: LanguageStats? = null + @OneToMany(mappedBy = "language", orphanRemoval = true, fetch = FetchType.LAZY) + var tasks: MutableList = mutableListOf() + @field:Size(max = 2000) @ActivityLoggedProp @Column(columnDefinition = "text") diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt index af42a4cb99..5a896c3d73 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt @@ -69,7 +69,7 @@ class Project( @Deprecated(message = "Project can be owned only by organization") var userOwner: UserAccount? = null - @ManyToOne(optional = true) + @ManyToOne(optional = true, fetch = FetchType.LAZY) lateinit var organizationOwner: Organization @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST]) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt index a58bf9c674..1df4a50708 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -4,18 +4,8 @@ import io.hypersistence.utils.hibernate.type.array.ListArrayType import io.tolgee.api.IUserAccount import io.tolgee.model.slackIntegration.SlackConfig import io.tolgee.model.slackIntegration.SlackUserConnection -import jakarta.persistence.CascadeType -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.FetchType -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.OneToMany -import jakarta.persistence.OneToOne -import jakarta.persistence.OrderBy +import io.tolgee.model.task.Task +import jakarta.persistence.* import jakarta.validation.constraints.NotBlank import org.hibernate.annotations.ColumnDefault import org.hibernate.annotations.Type @@ -102,6 +92,9 @@ data class UserAccount( @OneToMany(mappedBy = "userAccount", fetch = FetchType.LAZY, orphanRemoval = true) var slackConfig: MutableList = mutableListOf() + @ManyToMany(mappedBy = "assignees") + var tasks: MutableSet = mutableSetOf() + constructor( id: Long?, username: String?, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt index abb799bf3c..c123f1a1d4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt @@ -65,5 +65,9 @@ enum class ProjectPermissionType(val availableScopes: Array) { values().forEach { value -> result[value.name] = value.availableScopes } return result.toMap() } + + fun findByScope(scope: Scope): List { + return values().filter { it.availableScopes.contains(scope) } + } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt index 500ce29f4a..cb37938dcc 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt @@ -35,6 +35,8 @@ enum class Scope( CONTENT_DELIVERY_MANAGE("content-delivery.manage"), CONTENT_DELIVERY_PUBLISH("content-delivery.publish"), WEBHOOKS_MANAGE("webhooks.manage"), + TASKS_VIEW("tasks.view"), + TASKS_EDIT("tasks.edit"), ; fun expand() = Scope.expand(this) @@ -43,11 +45,8 @@ enum class Scope( private val keysView = HierarchyItem(KEYS_VIEW) private val translationsView = HierarchyItem(TRANSLATIONS_VIEW, listOf(keysView)) private val screenshotsView = HierarchyItem(SCREENSHOTS_VIEW, listOf(keysView)) - private val translationsEdit = - HierarchyItem( - TRANSLATIONS_EDIT, - listOf(translationsView), - ) + private val translationsEdit = HierarchyItem(TRANSLATIONS_EDIT, listOf(translationsView)) + private val tasksView = HierarchyItem(TASKS_VIEW) val hierarchy = HierarchyItem( @@ -82,6 +81,7 @@ enum class Scope( screenshotsView, ), ), + HierarchyItem(TASKS_EDIT, listOf(tasksView)), HierarchyItem(ACTIVITY_VIEW), HierarchyItem(LANGUAGES_EDIT), HierarchyItem(PROJECT_EDIT), @@ -161,6 +161,26 @@ enum class Scope( return expand(permittedScopes.toTypedArray()) } + /** + * Returns all possible scopes that contain required scope + * + * Example: When permittedScope === KEYS_VIEW, it returns [KEYS_VIEW, KEYS_EDIT, ADMIN] + * when user has any of these scopes, he effectively has KEYS_VIEW + */ + fun outer( + permittedScope: Scope, + root: HierarchyItem = hierarchy, + ): List { + val result = mutableSetOf() + root.requires.forEach { + result.addAll(outer(permittedScope, it)) + } + if (result.isNotEmpty() || root.scope === permittedScope) { + result.add(root.scope) + } + return result.toList() + } + fun fromValue(value: String): Scope { for (scope in values()) { if (scope.value == value) { 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 new file mode 100644 index 0000000000..2f4a18c98f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskState.kt @@ -0,0 +1,8 @@ +package io.tolgee.model.enums + +enum class TaskState { + NEW, + IN_PROGRESS, + DONE, + CLOSED, +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskType.kt new file mode 100644 index 0000000000..78d50928f2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskType.kt @@ -0,0 +1,6 @@ +package io.tolgee.model.enums + +enum class TaskType { + TRANSLATE, + REVIEW, +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt b/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt index fd2236b77d..9ec8c8e8ac 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt @@ -12,6 +12,7 @@ import io.tolgee.model.Project import io.tolgee.model.StandardAuditModel import io.tolgee.model.dataImport.WithKeyMeta import io.tolgee.model.key.screenshotReference.KeyScreenshotReference +import io.tolgee.model.task.TaskKey import io.tolgee.model.translation.Translation import jakarta.persistence.CascadeType import jakarta.persistence.Column @@ -56,6 +57,9 @@ class Key( @OneToMany(mappedBy = "key") var translations: MutableList = mutableListOf() + @OneToMany(mappedBy = "key", orphanRemoval = true) + var tasks: MutableList = mutableListOf() + @OneToOne(mappedBy = "key", fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST]) override var keyMeta: KeyMeta? = null diff --git a/backend/data/src/main/kotlin/io/tolgee/model/task/Task.kt b/backend/data/src/main/kotlin/io/tolgee/model/task/Task.kt new file mode 100644 index 0000000000..dfe7b300a4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/task/Task.kt @@ -0,0 +1,67 @@ +package io.tolgee.model.task + +import io.tolgee.activity.annotation.ActivityDescribingProp +import io.tolgee.activity.annotation.ActivityLoggedEntity +import io.tolgee.activity.annotation.ActivityLoggedProp +import io.tolgee.model.* +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType +import jakarta.persistence.* +import jakarta.validation.constraints.Size +import java.util.* + +@Entity +@Table( + uniqueConstraints = [ + UniqueConstraint( + columnNames = ["project_id", "number"], + name = "project_number_unique", + ), + ], +) +@ActivityLoggedEntity +class Task : StandardAuditModel() { + @ManyToOne(fetch = FetchType.LAZY) + var project: Project = Project() // Initialize to avoid null issues + + @ActivityLoggedProp + @ActivityDescribingProp + var number: Long = 1L + + @ActivityLoggedProp + @ActivityDescribingProp + @field:Size(max = 255) + @Column(length = 255) + var name: String = "" + + @ActivityLoggedProp + @field:Size(max = 2000) + @Column(length = 2000) + var description: String = "" + + @ActivityLoggedProp + @ActivityDescribingProp + @Enumerated(EnumType.STRING) + var type: TaskType = TaskType.TRANSLATE + + @ManyToOne(fetch = FetchType.LAZY) + lateinit var language: Language + + @ActivityLoggedProp + var dueDate: Date? = null + + @ManyToMany(fetch = FetchType.LAZY) + var assignees: MutableSet = mutableSetOf() + + @OneToMany(mappedBy = "task", fetch = FetchType.LAZY) + var keys: MutableSet = mutableSetOf() + + @ManyToOne(fetch = FetchType.LAZY, optional = true) + var author: UserAccount? = null + + @ActivityLoggedProp + @Enumerated(EnumType.STRING) + var state: TaskState = TaskState.NEW + + var closedAt: Date? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/task/TaskKey.kt b/backend/data/src/main/kotlin/io/tolgee/model/task/TaskKey.kt new file mode 100644 index 0000000000..47e8e50454 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/task/TaskKey.kt @@ -0,0 +1,19 @@ +package io.tolgee.model.task + +import io.tolgee.model.UserAccount +import io.tolgee.model.key.Key +import jakarta.persistence.* + +@Entity +@IdClass(TaskKeyId::class) +class TaskKey( + @Id + @ManyToOne(fetch = FetchType.LAZY) + var task: Task = Task(), + @Id + @ManyToOne(fetch = FetchType.LAZY) + var key: Key = Key(), + var done: Boolean = false, + @ManyToOne(fetch = FetchType.LAZY) + var author: UserAccount? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/task/TaskKeyId.kt b/backend/data/src/main/kotlin/io/tolgee/model/task/TaskKeyId.kt new file mode 100644 index 0000000000..03d4755054 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/task/TaskKeyId.kt @@ -0,0 +1,13 @@ +package io.tolgee.model.task + +import io.tolgee.model.key.Key +import jakarta.persistence.FetchType +import jakarta.persistence.ManyToOne +import java.io.Serializable + +data class TaskKeyId( + @ManyToOne(fetch = FetchType.LAZY) + var task: Task = Task(), + @ManyToOne(fetch = FetchType.LAZY) + var key: Key = Key(), +) : Serializable diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/KeyTaskView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/KeyTaskView.kt new file mode 100644 index 0000000000..ca4d3744f4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/KeyTaskView.kt @@ -0,0 +1,12 @@ +package io.tolgee.model.views + +import io.tolgee.model.enums.TaskType + +class KeyTaskView( + val number: Long, + val languageId: Long, + val languageTag: String, + val done: Boolean, + val userAssigned: Boolean, + val type: TaskType, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt index c94974291f..f46857702d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt @@ -20,6 +20,7 @@ data class KeyWithTranslationsView( ) { lateinit var keyTags: List var screenshots: Collection? = null + var tasks: List? = null companion object { fun of( diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/KeysScopeView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/KeysScopeView.kt new file mode 100644 index 0000000000..c3845f4a39 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/KeysScopeView.kt @@ -0,0 +1,7 @@ +package io.tolgee.model.views + +interface KeysScopeView { + val keyCount: Long + val characterCount: Long + val wordCount: Long +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/TaskPerUserReportView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/TaskPerUserReportView.kt new file mode 100644 index 0000000000..ed38d8ea8e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/TaskPerUserReportView.kt @@ -0,0 +1,10 @@ +package io.tolgee.model.views + +import io.tolgee.model.UserAccount + +interface TaskPerUserReportView { + val user: UserAccount + val doneItems: Long + val baseCharacterCount: Long + val baseWordCount: Long +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/TaskScopeView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/TaskScopeView.kt new file mode 100644 index 0000000000..d5f377c11c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/TaskScopeView.kt @@ -0,0 +1,9 @@ +package io.tolgee.model.views + +interface TaskScopeView { + val taskId: Long? + val totalItems: Long + val doneItems: Long + val baseCharacterCount: Long + val baseWordCount: Long +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/TaskWithScopeView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/TaskWithScopeView.kt new file mode 100644 index 0000000000..667042af01 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/TaskWithScopeView.kt @@ -0,0 +1,29 @@ +package io.tolgee.model.views + +import io.tolgee.model.Language +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType +import io.tolgee.model.task.TaskKey +import java.util.* + +data class TaskWithScopeView( + val project: Project, + val number: Long, + val name: String, + val description: String, + val type: TaskType, + val language: Language, + val dueDate: Date?, + val assignees: MutableSet, + val keys: MutableSet, + val author: UserAccount, + val createdAt: Date?, + val state: TaskState, + val closedAt: Date?, + val totalItems: Long, + val doneItems: Long, + val baseWordCount: Long, + val baseCharacterCount: Long, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/TranslationToTaskView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/TranslationToTaskView.kt new file mode 100644 index 0000000000..13f1da5453 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/TranslationToTaskView.kt @@ -0,0 +1,13 @@ +package io.tolgee.model.views + +import io.tolgee.model.enums.TaskType + +interface TranslationToTaskView { + var keyId: Long + var languageId: Long + var languageTag: String + var taskNumber: Long + var taskDone: Boolean + var taskAssigned: Boolean + var taskType: TaskType +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt index fa1e4adc13..c8ee1a227a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt @@ -54,6 +54,11 @@ interface KeyRepository : JpaRepository { fun deleteAllByIdIn(ids: Collection) + fun findAllByProjectIdAndIdIn( + projectId: Long, + ids: Collection, + ): List + fun findAllByIdIn(ids: Collection): List @Query( diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt index 3f3091144d..758c45002d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt @@ -1,6 +1,7 @@ package io.tolgee.repository import io.tolgee.dtos.cacheable.LanguageDto +import io.tolgee.dtos.request.language.LanguageFilters import io.tolgee.model.Language import org.springframework.context.annotation.Lazy import org.springframework.data.domain.Page @@ -10,6 +11,17 @@ import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository import java.util.* +const val LANGUAGE_FILTERS = """ + ( + :#{#filters.filterId} is null + or l.id in :#{#filters.filterId} + ) + and ( + :#{#filters.filterNotId} is null + or l.id not in :#{#filters.filterNotId} + ) +""" + @Repository @Lazy interface LanguageRepository : JpaRepository { @@ -47,11 +59,13 @@ interface LanguageRepository : JpaRepository { ) from Language l where l.project.id = :projectId and l.deletedAt is null + and $LANGUAGE_FILTERS """, ) fun findAllByProjectId( projectId: Long?, pageable: Pageable, + filters: LanguageFilters, ): Page fun findAllByTagInAndProjectId( diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt index 18a11a0dd0..73c1ed1aa3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -1,5 +1,6 @@ package io.tolgee.repository +import io.tolgee.dtos.request.project.ProjectFilters import io.tolgee.model.Organization import io.tolgee.model.Project import io.tolgee.model.views.ProjectView @@ -27,6 +28,17 @@ interface ProjectRepository : JpaRepository { left join fetch o.basePermission left join OrganizationRole role on role.organization = o and role.user.id = :userAccountId """ + + const val FILTERS = """ + ( + :#{#filters.filterId} is null + or r.id in :#{#filters.filterId} + ) + and ( + :#{#filters.filterNotId} is null + or r.id not in :#{#filters.filterNotId} + ) + """ } @Query( @@ -54,6 +66,14 @@ interface ProjectRepository : JpaRepository { or lower(o.name) like lower(concat('%', cast(:search as text),'%'))) ) and (:organizationId is null or o.id = :organizationId) and r.deletedAt is null + and ( + :#{#filters.filterId} is null + or r.id in :#{#filters.filterId} + ) + and ( + :#{#filters.filterNotId} is null + or r.id not in :#{#filters.filterNotId} + ) """, ) fun findAllPermitted( @@ -61,6 +81,7 @@ interface ProjectRepository : JpaRepository { pageable: Pageable, @Param("search") search: String? = null, organizationId: Long? = null, + filters: ProjectFilters, ): Page fun findAllByOrganizationOwnerId(organizationOwnerId: Long): List diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/TaskKeyRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/TaskKeyRepository.kt new file mode 100644 index 0000000000..c87dcaad43 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/TaskKeyRepository.kt @@ -0,0 +1,12 @@ +package io.tolgee.repository + +import io.tolgee.model.task.Task +import io.tolgee.model.task.TaskKey +import io.tolgee.model.task.TaskKeyId +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TaskKeyRepository : JpaRepository { + fun deleteByTask(task: Task) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt new file mode 100644 index 0000000000..8928008bfb --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt @@ -0,0 +1,357 @@ +package io.tolgee.repository + +import io.tolgee.dtos.request.task.TaskFilters +import io.tolgee.dtos.request.task.TranslationScopeFilters +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.TaskType +import io.tolgee.model.task.Task +import io.tolgee.model.views.KeysScopeView +import io.tolgee.model.views.TaskPerUserReportView +import io.tolgee.model.views.TaskScopeView +import io.tolgee.model.views.TranslationToTaskView +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import java.util.Optional + +const val TASK_SEARCH = """ + ( + cast(:search as text) is null + or lower(tk.name) like lower(concat('%', cast(:search as text),'%')) + ) +""" + +const val TASK_FILTERS = """ + ( + :#{#filters.filterNotState} is null + or tk.state not in :#{#filters.filterNotState} + ) + and ( + :#{#filters.filterState} is null + or tk.state in :#{#filters.filterState} + ) + and ( + :#{#filters.filterType} is null + or tk.type in :#{#filters.filterType} + ) + and ( + :#{#filters.filterId} is null + or tk.number in :#{#filters.filterId} + ) + and ( + :#{#filters.filterNotId} is null + or tk.number not in :#{#filters.filterNotId} + ) + and ( + :#{#filters.filterProject} is null + or tk.project.id in :#{#filters.filterProject} + ) + and ( + :#{#filters.filterNotProject} is null + or tk.project.id not in :#{#filters.filterNotProject} + ) + and ( + :#{#filters.filterLanguage} is null + or tk.language.id in :#{#filters.filterLanguage} + ) + and ( + :#{#filters.filterAssignee} is null + or exists ( + select 1 + from tk.assignees u + where u.id in :#{#filters.filterAssignee} + ) + ) + and ( + :#{#filters.filterKey} is null + or exists ( + select 1 + from tk.keys tt + where element(tt).key.id in :#{#filters.filterKey} + ) + ) + and ( + tk.state != 'DONE' + or :#{#filters.filterDoneMinClosedAt} is null + or tk.closedAt > :#{#filters.filterDoneMinClosedAt} + ) +""" + +@Repository +interface TaskRepository : JpaRepository { + @Query( + """ + select tk + from Task tk + left join tk.language l + where + l.deletedAt is null + and tk.project.id = :projectId + and $TASK_SEARCH + and $TASK_FILTERS + """, + ) + fun getAllByProjectId( + projectId: Long, + pageable: Pageable, + search: String?, + filters: TaskFilters, + ): Page + + @Query( + """ + select tk + from Task tk + join tk.assignees u on u.id = :userId + left join tk.language l + where l.deletedAt is null + and $TASK_SEARCH + and $TASK_FILTERS + """, + ) + fun getAllByAssignee( + userId: Long, + pageable: Pageable, + search: String?, + filters: TaskFilters, + ): Page + + @Query( + nativeQuery = true, + value = """ + select distinct on (l.id, tt.key_id) + tt.key_id as keyId, + l.id as languageId, + l.tag as languageTag, + t.number as taskNumber, + tt.done as taskDone, + CASE WHEN u.id IS NULL THEN FALSE ELSE TRUE END as taskAssigned, + t.type as taskType + from task t + join task_key tt on (t.id = tt.task_id) + left join task_assignees ta on (ta.tasks_id = t.id) + left join user_account u on ta.assignees_id = u.id + left join language l on (t.language_id = l.id) + where + tt.key_id in :keyIds + and u.id = :currentUserId + 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 + """, + ) + fun getByKeyId( + currentUserId: Long, + keyIds: Collection, + ): List + + @Query( + """ + select t + from Task t + left join fetch t.assignees + left join fetch t.author + left join fetch t.project + left join fetch t.language + where t in :tasks + """, + ) + fun getByIdsWithAllPrefetched(tasks: Collection): List + + @Query( + """ + select t + from Task t + left join t.language l + where + t.project = :project + and l.deletedAt is null + order by t.number desc + """, + ) + fun findByProjectOrderByNumberDesc(project: Project): List + + @Query( + nativeQuery = true, + value = """ + select key.id + from key + left join ( + select key.id as key_id from key + join task_key on (key.id = task_key.key_id) + join task on (task_key.task_id = task.id) + left join language l on (task.language_id = l.id) + where task.type = :taskType + and task.language_id = :languageId + and (task.state = 'IN_PROGRESS' or task.state = 'NEW') + and l.deleted_at is null + ) as task on task.key_id = key.id + left join translation t on t.key_id = key.id and t.language_id = :languageId + where key.project_id = :projectId + and key.id in :keyIds + and task IS NULL + and ( + COALESCE(t.state, 0) in :#{#filters.filterStateOrdinal} -- item fits the filter + or ( + -- item fits the filter + :#{#filters.filterOutdated} = true + and COALESCE(t.outdated, false) = true + ) or ( + -- no filter is applied + COALESCE(:#{#filters.filterOutdated}, false) = false + and :#{#filters.filterState} is null + ) + ) + """, + ) + fun getKeysWithoutTask( + projectId: Long, + languageId: Long, + taskType: String, + keyIds: Collection, + filters: TranslationScopeFilters = TranslationScopeFilters(), + ): List + + @Query( + """ + select k.id + from Key k + left join k.tasks tt + left join tt.task t + where k.project.id = :projectId and t.number = :taskNumber + """, + ) + fun getTaskKeys( + projectId: Long, + taskNumber: Long, + ): List + + @Query( + """ + select count(k.id) as keyCount, coalesce(sum(t.characterCount), 0) as characterCount, coalesce(sum(t.wordCount), 0) as wordCount + from Key k + left join k.translations as t + where k.project.id = :projectId + and (t.language.id = :baseLangId or t.id is NULL) + and k.id in :keyIds + """, + ) + fun calculateScope( + projectId: Long, + baseLangId: Long, + keyIds: Collection, + ): KeysScopeView + + @Query( + value = """ + select + tk.id as taskId, + count(k.id) as totalItems, + coalesce(sum(case when tt.done then 1 else 0 end), 0) as doneItems, + coalesce(sum(bt.characterCount), 0) as baseCharacterCount, + coalesce(sum(bt.wordCount), 0) as baseWordCount + from Task tk + left join tk.project p + left join tk.keys tt + left join Key k on element(tt).key.id = k.id + left join k.translations bt on (bt.language.id = p.baseLanguage.id) + where tk in :tasks + group by tk.id, tk.project.id + """, + ) + fun getTasksScopes(tasks: Collection): List + + @Query( + """ + select u as user, count(k.id) as doneItems, coalesce(sum(btr.characterCount), 0) as baseCharacterCount, coalesce(sum(btr.wordCount), 0) as baseWordCount + from Task tk + left join tk.keys as tt + left join tt.author as u + left join Key k on element(tt).key.id = k.id + left join k.translations as btr on btr.language.id = :baseLangId + where tk.project.id = :projectId + and tk.number = :taskNumber + and tt.done + and u.id is not NULL + group by u + """, + ) + fun perUserReport( + projectId: Long, + taskNumber: Long, + baseLangId: Long, + ): List + + @Query( + """ + select u + from UserAccount u + join u.tasks tk + where tk.number = :taskNumber + and tk.project.id = :projectId + and u.id = :userId + """, + ) + fun findAssigneeById( + projectId: Long, + taskNumber: Long, + userId: Long, + ): List + + @Query( + """ + select u + from UserAccount u + join u.tasks tk + join tk.keys tt + where (:type is NULL OR tk.type = :type) + and tk.language.id = :languageId + and (tk.state = 'IN_PROGRESS' or tk.state = 'NEW') + and u.id = :userId + and element(tt).key.id = :keyId + """, + ) + fun findAssigneeByKey( + keyId: Long, + languageId: Long, + userId: Long, + type: TaskType? = null, + ): List + + @Query( + """ + select distinct t.number + from Task t + join t.keys tt + join Task at on (at.number = :taskNumber and at.project.id = :projectId) + join at.keys att + join Key k on (element(att).key.id = k.id and element(tt).key.id = k.id) + where (t.number > :taskNumber or t.type != at.type) + and t.language = at.language + and t.type >= at.type + and (t.state = 'IN_PROGRESS' or t.state = 'NEW') + """, + ) + fun getBlockingTaskNumbers( + projectId: Long, + taskNumber: Long, + ): List + + @Query( + """ + from Task t + left join fetch t.assignees + left join fetch t.project + left join fetch t.language + where t.number = :taskNumber + and t.project.id = :projectId + """, + ) + fun findByNumber( + projectId: Long, + taskNumber: Long, + ): Optional +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt index e3691d88ee..319969cc04 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt @@ -73,6 +73,18 @@ interface TranslationRepository : JpaRepository { ) fun getAllByKeyIdIn(keyIds: Collection): Collection + @Query( + """ + from Translation t + where t.language.id = :languageId + and t.key.id in :ids + """, + ) + fun findAllByLanguageIdAndKeyIdIn( + languageId: Long, + ids: Collection, + ): List + @Query( """from Translation t join fetch t.key k diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt index bc0de7fb04..aab0f7f6a3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -1,6 +1,7 @@ package io.tolgee.repository import io.tolgee.dtos.queryResults.UserAccountView +import io.tolgee.dtos.request.task.UserAccountFilters import io.tolgee.model.UserAccount import io.tolgee.model.views.UserAccountInProjectView import io.tolgee.model.views.UserAccountWithOrganizationRoleView @@ -13,6 +14,85 @@ import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository import java.util.* +const val USER_FILTERS = """ + ( + :#{#filters.filterId} is null + or ua.id in :#{#filters.filterId} + ) + and ( + :#{#filters.filterNotId} is null + or ua.id not in :#{#filters.filterNotId} + ) +""" + +const val PROJECT_PERMISSIONS_CTE = """ + with projectPermissions as ( + select + pe.id, + pe.user_id, + pe.scopes, + pe.project_id, + pe.type, + array_remove(array_agg(pe_view.view_languages_id), null) as view_languages, + array_remove(array_agg(pe_edit.languages_id), null) as edit_languages, + array_remove(array_agg(pe_state.state_change_languages_id), null) as state_languages + from permission pe + left join permission_view_languages pe_view on pe.id = pe_view.permission_id + left join permission_languages pe_edit on pe.id = pe_edit.permission_id + left join permission_state_change_languages pe_state on pe.id = pe_state.permission_id + where pe.project_id = :projectId + group by pe.id, pe.user_id, pe.scopes, pe.project_id, pe.type + )""" +const val PROJECT_PERMISSIONS_MAIN = """ + from user_account ua + left join projectPermissions pe on pe.user_id = ua.id + left join project p on p.id = :projectId + left join organization o on p.organization_owner_id = o.id + left join organization_role o_r on o_r.user_id = ua.id and o_r.organization_id = o.id + left join permission ope on ope.organization_id = o.id + where ( + pe.project_id= :projectId + or o_r.user_id is not null + ) and ( + :filterId is null + or ua.id in :filterId + ) and ( + (:scopes is null and :projectRoles is null) + or ( + ( + cast(:scopes as character varying[]) && pe.scopes + or pe.type in :projectRoles + ) and ( + :viewLanguageId is null or + cardinality(pe.view_languages) = 0 or + :viewLanguageId = any(pe.view_languages) + ) and ( + :editLanguageId is null or + cardinality(pe.edit_languages) = 0 or + :editLanguageId = any(pe.edit_languages) + ) and ( + :stateLanguageId is null or + cardinality(pe.state_languages) = 0 or + :stateLanguageId = any(pe.state_languages) + ) + ) + or ( + pe.id is null + and ( + cast(:scopes as character varying[]) && ope.scopes + or ope.type in :projectRoles + ) + ) + or o_r.type = 1 + ) and ( + cast(:search as text) is null + or ( + lower(ua.name) like lower(concat('%', cast(:search as text),'%')) + or lower(ua.username) like lower(concat('%', cast(:search as text),'%')) + ) + ) +""" + @Repository @Lazy interface UserAccountRepository : JpaRepository { @@ -117,6 +197,7 @@ interface UserAccountRepository : JpaRepository { like lower(concat('%', cast(:search as text),'%')) or lower(ua.username) like lower(concat('%', cast(:search as text),'%'))) or cast(:search as text) is null) and ua.deletedAt is null + and $USER_FILTERS """, ) fun getAllInProject( @@ -124,6 +205,7 @@ interface UserAccountRepository : JpaRepository { pageable: Pageable, search: String? = "", exceptUserId: Long? = null, + filters: UserAccountFilters, ): Page @Query( @@ -211,4 +293,21 @@ interface UserAccountRepository : JpaRepository { """, ) fun findDemoByUsernames(usernames: List): List + + @Query( + nativeQuery = true, + value = PROJECT_PERMISSIONS_CTE + "select ua.id" + PROJECT_PERMISSIONS_MAIN, + countQuery = PROJECT_PERMISSIONS_CTE + "select count(ua.id)" + PROJECT_PERMISSIONS_MAIN, + ) + fun findUsersWithMinimalPermissions( + filterId: Collection, + scopes: String?, + projectRoles: Collection, + projectId: Long, + viewLanguageId: Long?, + editLanguageId: Long?, + stateLanguageId: Long?, + search: String?, + pageable: Pageable, + ): Page } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt b/backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt new file mode 100644 index 0000000000..8e7c139c77 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt @@ -0,0 +1,484 @@ +package io.tolgee.service + +import io.tolgee.component.task.TaskReportHelper +import io.tolgee.constants.Message +import io.tolgee.dtos.request.task.* +import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Language +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType +import io.tolgee.model.key.Key +import io.tolgee.model.task.Task +import io.tolgee.model.task.TaskKey +import io.tolgee.model.task.TaskKeyId +import io.tolgee.model.views.* +import io.tolgee.repository.TaskKeyRepository +import io.tolgee.repository.TaskRepository +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.key.KeyService +import io.tolgee.service.language.LanguageService +import io.tolgee.service.security.SecurityService +import io.tolgee.service.translation.TranslationService +import jakarta.persistence.EntityManager +import jakarta.transaction.Transactional +import org.apache.commons.io.output.ByteArrayOutputStream +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component +import java.util.* + +@Component +class TaskService( + private val taskRepository: TaskRepository, + private val entityManager: EntityManager, + private val languageService: LanguageService, + @Lazy + private val securityService: SecurityService, + private val taskKeyRepository: TaskKeyRepository, + private val translationService: TranslationService, + private val keyService: KeyService, + private val authenticationFacade: AuthenticationFacade, + @Lazy + @Autowired + private val taskService: TaskService, +) { + fun getAllPaged( + project: Project, + pageable: Pageable, + search: String?, + filters: TaskFilters, + ): Page { + val pagedTasks = taskRepository.getAllByProjectId(project.id, pageable, search, filters) + val withPrefetched = getPrefetchedTasks(pagedTasks.content) + return PageImpl(getTasksWithScope(withPrefetched), pageable, pagedTasks.totalElements) + } + + fun getUserTasksPaged( + userId: Long, + pageable: Pageable, + search: String?, + filters: TaskFilters, + ): Page { + val pagedTasks = taskRepository.getAllByAssignee(userId, pageable, search, filters) + val withPrefetched = getPrefetchedTasks(pagedTasks.content) + return PageImpl(getTasksWithScope(withPrefetched), pageable, pagedTasks.totalElements) + } + + fun getPrefetchedTasks(tasks: Collection): List { + val ids = tasks.map { it.id }.mapIndexed { i, v -> Pair(v, i) }.toMap() + val data = taskRepository.getByIdsWithAllPrefetched(tasks) + // return tasks in the same order + return data.sortedBy { ids[it.id] } + } + + @Transactional + fun createMultipleTasks( + project: Project, + dtos: Collection, + filters: TranslationScopeFilters, + ) { + dtos.forEach { + createTask(project, it, filters) + } + } + + @Transactional + fun createTask( + project: Project, + dto: CreateTaskRequest, + filters: TranslationScopeFilters, + ): TaskWithScopeView { + var lastErr = DataIntegrityViolationException("Error") + repeat(100) { + // necessary for proper transaction creation + try { + val task = taskService.createTaskInTransaction(project, dto, filters) + entityManager.flush() + return getTasksWithScope(listOf(task)).first() + } catch (e: DataIntegrityViolationException) { + lastErr = e + } + } + throw lastErr + } + + @Transactional() + fun createTaskInTransaction( + project: Project, + dto: CreateTaskRequest, + filters: TranslationScopeFilters, + ): Task { + // Find the maximum ID for the given project + val lastTask = taskRepository.findByProjectOrderByNumberDesc(project).firstOrNull() + val newNumber = (lastTask?.number ?: 0L) + 1 + + val language = checkLanguage(dto.languageId!!, project) + val assignees = checkAssignees(dto.assignees ?: mutableSetOf(), project) + val keys = + getOnlyProjectKeys( + project, + dto.languageId!!, + dto.type, + dto.keys ?: mutableSetOf(), + filters, + ) + + val task = Task() + + task.number = newNumber + task.project = project + task.name = dto.name + task.type = dto.type + task.description = dto.description + task.dueDate = dto.dueDate?.let { Date(it) } + task.language = language + task.assignees = assignees + task.author = entityManager.getReference(UserAccount::class.java, authenticationFacade.authenticatedUser.id) + task.createdAt = Date() + task.state = TaskState.NEW + taskRepository.saveAndFlush(task) + + val taskKeys = keys.map { TaskKey(task, entityManager.getReference(Key::class.java, it)) }.toMutableSet() + task.keys = taskKeys + taskKeyRepository.saveAll(taskKeys) + + return task + } + + @Transactional + fun updateTask( + projectEntity: Project, + taskNumber: Long, + dto: UpdateTaskRequest, + ): TaskWithScopeView { + val task = + taskRepository.findByNumber(projectEntity.id, taskNumber).or { + throw NotFoundException(Message.TASK_NOT_FOUND) + }.get() + + dto.name?.let { + task.name = it + } + + dto.description?.let { + task.description = it + } + + dto.dueDate?.let { + if (it < 0L) { + task.dueDate = null + } else { + task.dueDate = Date(it) + } + } + + dto.assignees?.let { + task.assignees = checkAssignees(dto.assignees!!, projectEntity) + } + + taskRepository.saveAndFlush(task) + + return getTasksWithScope(listOf(task)).first() + } + + @Transactional + fun setTaskState( + projectEntity: Project, + taskNumber: Long, + state: TaskState, + ): TaskWithScopeView { + val task = + taskRepository.findByNumber(projectEntity.id, taskNumber).or { + throw NotFoundException(Message.TASK_NOT_FOUND) + }.get() + val taskWithScope = getTasksWithScope(listOf(task)).first() + if (state == TaskState.DONE && taskWithScope.doneItems != taskWithScope.totalItems) { + throw BadRequestException(Message.TASK_NOT_FINISHED) + } + if (state == TaskState.NEW || state == TaskState.IN_PROGRESS) { + task.state = if (taskWithScope.doneItems == 0L) TaskState.NEW else TaskState.IN_PROGRESS + } else { + task.closedAt = Date() + task.state = state + } + taskRepository.saveAndFlush(task) + return getTask(projectEntity, taskNumber) + } + + @Transactional + fun getTask( + projectEntity: Project, + taskNumber: Long, + ): TaskWithScopeView { + val task = + taskRepository.findByNumber(projectEntity.id, taskNumber).or { + throw NotFoundException(Message.TASK_NOT_FOUND) + }.get() + return getTasksWithScope(listOf(task)).first() + } + + @Transactional + fun updateTaskKeys( + projectEntity: Project, + taskNumber: Long, + dto: UpdateTaskKeysRequest, + ) { + val task = + taskRepository.findByNumber(projectEntity.id, taskNumber).or { + throw NotFoundException(Message.TASK_NOT_FOUND) + }.get() + + dto.removeKeys?.let { toRemove -> + val taskKeysToRemove = + task.keys.filter { + toRemove.contains( + it.key.id, + ) + }.toMutableSet() + task.keys = task.keys.subtract(taskKeysToRemove).toMutableSet() + taskKeyRepository.deleteAll(taskKeysToRemove) + } + + dto.addKeys?.let { toAdd -> + val existingKeys = task.keys.map { it.key.id }.toMutableSet() + val nonExistingKeyIds = toAdd.subtract(existingKeys).toMutableSet() + val taskKeysToAdd = + toAdd + .filter { nonExistingKeyIds.contains(it) } + .map { TaskKey(task, entityManager.getReference(Key::class.java, it)) } + task.keys = task.keys.union(taskKeysToAdd).toMutableSet() + taskKeyRepository.saveAll(taskKeysToAdd) + } + } + + @Transactional + fun updateTaskKey( + projectEntity: Project, + taskNumber: Long, + keyId: Long, + dto: UpdateTaskKeyRequest, + ): UpdateTaskKeyResponse { + val task = + taskRepository.findByNumber(projectEntity.id, taskNumber).or { + throw NotFoundException(Message.TASK_NOT_FOUND) + }.get() + + if (task.state == TaskState.CLOSED || task.state == TaskState.DONE) { + throw BadRequestException(Message.TASK_NOT_OPEN) + } + + val taskWithScope = getTasksWithScope(listOf(task)).first() + + val taskKey = + taskKeyRepository.findById( + TaskKeyId( + task = task, + key = entityManager.getReference(Key::class.java, keyId), + ), + ).or { + throw NotFoundException(Message.TASK_NOT_FOUND) + }.get() + + val previousValue = taskKey.done + val changed = previousValue != dto.done + + val totalDone = taskWithScope.doneItems + if (dto.done) 1 else -1 + val allDone = totalDone == taskWithScope.totalItems + if (changed) { + if (dto.done == true) { + taskKey.author = + entityManager.getReference( + UserAccount::class.java, + authenticationFacade.authenticatedUser.id, + ) + } else { + taskKey.author = null + } + taskKey.done = dto.done + taskKeyRepository.save(taskKey) + if (totalDone == 0L) { + task.state = TaskState.NEW + } else { + task.state = TaskState.IN_PROGRESS + } + taskRepository.save(task) + } + + return UpdateTaskKeyResponse( + done = taskKey.done, + taskFinished = allDone, + ) + } + + fun findAssigneeById( + projectId: Long, + taskNumber: Long, + userId: Long, + ): List { + return taskRepository.findAssigneeById(projectId, taskNumber, userId) + } + + fun findAssigneeByKey( + keyId: Long, + languageId: Long, + userId: Long, + type: TaskType? = null, + ): List { + return taskRepository.findAssigneeByKey(keyId, languageId, userId, type) + } + + @Transactional + fun calculateScope( + projectEntity: Project, + dto: CalculateScopeRequest, + filters: TranslationScopeFilters, + ): KeysScopeView { + val language = languageService.get(dto.language, projectEntity.id) + val relevantKeys = + taskRepository.getKeysWithoutTask( + projectEntity.id, + language.id, + dto.type.toString(), + dto.keys!!, + filters, + ) + return taskRepository.calculateScope( + projectEntity.id, + projectEntity.baseLanguage!!.id, + relevantKeys, + ) + } + + @Transactional + fun getTaskKeys( + projectEntity: Project, + taskNumber: Long, + ): List { + return taskRepository.getTaskKeys(projectEntity.id, taskNumber) + } + + fun getBlockingTasks( + projectEntity: Project, + taskNumber: Long, + ): List { + return taskRepository.getBlockingTaskNumbers(projectEntity.id, taskNumber) + } + + fun getKeysWithTasks( + userId: Long, + keyIds: Collection, + ): Map> { + val data = taskRepository.getByKeyId(userId, keyIds) + val result = mutableMapOf>() + data.forEach { + val existing = result[it.keyId] ?: mutableListOf() + existing.add(it) + result.set(it.keyId, existing) + } + return result + } + + fun getReport( + projectEntity: Project, + taskNumber: Long, + ): List { + return taskRepository.perUserReport( + projectEntity.id, + taskNumber, + projectEntity.baseLanguage!!.id, + ) + } + + private fun getOnlyProjectKeys( + project: Project, + languageId: Long, + type: TaskType, + keys: Collection, + filters: TranslationScopeFilters, + ): MutableSet { + return taskRepository.getKeysWithoutTask( + project.id, + languageId, + type.toString(), + keys, + filters, + ).toMutableSet() + } + + private fun checkAssignees( + assignees: MutableSet, + project: Project, + ): MutableSet { + return assignees.map { + val permission = securityService.getProjectPermissionScopesNoApiKey(project.id, it) + if (permission.isNullOrEmpty()) { + throw BadRequestException(Message.USER_HAS_NO_PROJECT_ACCESS) + } + entityManager.getReference(UserAccount::class.java, it) + }.toMutableSet() + } + + private fun checkLanguage( + language: Long, + project: Project, + ): Language { + val allLanguages = languageService.findAll(project.id).associateBy { it.id } + if (allLanguages[language] == null) { + throw BadRequestException(Message.LANGUAGE_NOT_FROM_PROJECT) + } else { + return entityManager.getReference(Language::class.java, language) + } + } + + private fun getTasksWithScope(tasks: Collection): List { + val scopes = taskRepository.getTasksScopes(tasks) + return tasks.map { task -> + val scope = scopes.find { it.taskId == task.id }!! + TaskWithScopeView( + project = task.project, + number = task.number, + name = task.name, + description = task.description, + type = task.type, + language = task.language, + dueDate = task.dueDate, + assignees = task.assignees, + keys = task.keys, + author = task.author!!, + createdAt = task.createdAt, + state = task.state, + closedAt = task.closedAt, + totalItems = scope.totalItems, + doneItems = scope.doneItems, + baseWordCount = scope.baseWordCount, + baseCharacterCount = scope.baseCharacterCount, + ) + } + } + + fun getExcelFile( + projectEntity: Project, + taskNumber: Long, + ): ByteArray { + val task = getTask(projectEntity, taskNumber) + val report = getReport(projectEntity, taskNumber) + + val workbook = TaskReportHelper(task, report).generateExcelReport() + + // Write the workbook to a byte array output stream + val byteArrayOutputStream = ByteArrayOutputStream() + workbook.use { wb -> + wb.write(byteArrayOutputStream) + } + + val byteArray = byteArrayOutputStream.toByteArray() + return byteArray + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt index 8d5f52d8d6..d757a7c787 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt @@ -192,7 +192,7 @@ class StoredDataImporter( private fun checkTranslationPermissions() { val langs = translationsToSave.map { it.second.language }.toSet().map { it.id } - securityService.checkLanguageTranslatePermission(import.project.id, langs) + securityService.checkLanguageTranslatePermission(import.project.id, langs, null) } private fun checkKeyPermissions() { diff --git a/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt b/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt index f3d4c08d0f..501003c071 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt @@ -412,10 +412,17 @@ class KeyService( return result } - fun find(id: List): List { + fun find(id: Collection): List { return keyRepository.findAllByIdIn(id) } + fun find( + projectId: Long, + ids: Collection, + ): List { + return keyRepository.findAllByProjectIdAndIdIn(projectId, ids) + } + @Transactional fun getDisabledLanguages( projectId: Long, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageHardDeleter.kt b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageHardDeleter.kt index dc8d602f66..f6c61bbd30 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageHardDeleter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageHardDeleter.kt @@ -1,8 +1,11 @@ package io.tolgee.service.language import io.tolgee.model.Language +import io.tolgee.model.task.Task import io.tolgee.model.translation.Translation import io.tolgee.repository.LanguageRepository +import io.tolgee.repository.TaskRepository +import io.tolgee.repository.TranslationRepository import jakarta.persistence.EntityManager import org.springframework.context.ApplicationContext @@ -21,26 +24,42 @@ class LanguageHardDeleter( fun delete() { val languageWithData = getWithFetchedTranslations(language) val allTranslations = getAllTranslations(languageWithData) - languageWithData.translations = allTranslations - languageRepository.delete(language) + val tasks = getAllTasks(languageWithData) + translationRepository.deleteAll(allTranslations) + taskRepository.deleteAll(tasks) + languageRepository.delete(languageWithData) entityManager.flush() } private fun getAllTranslations(languageWithData: Language) = languageWithData.translations.chunked(30000).flatMap { - entityManager.createQuery( - """from Translation t + val withComments = + entityManager.createQuery( + """from Translation t join fetch t.key k left join fetch k.keyMeta km left join fetch k.namespace left join fetch t.comments where t.id in :ids""", - Translation::class.java, - ) - .setParameter("ids", it.map { it.id }) - .resultList + Translation::class.java, + ) + .setParameter("ids", it.map { it.id }) + .resultList + + withComments }.toMutableList() + fun getAllTasks(languageWithData: Language) = + entityManager.createQuery( + """from Task tk + join fetch tk.keys + where tk.language = :languageWithData""", + Task::class.java, + ) + .setParameter("languageWithData", languageWithData) + .resultList + .toMutableList() + private fun getWithFetchedTranslations(language: Language): Language { return entityManager.createQuery( """ @@ -60,4 +79,12 @@ class LanguageHardDeleter( private val languageRepository by lazy { applicationContext.getBean(LanguageRepository::class.java) } + + private val translationRepository by lazy { + applicationContext.getBean(TranslationRepository::class.java) + } + + private val taskRepository by lazy { + applicationContext.getBean(TaskRepository::class.java) + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt index 6604eb8d01..7caebff7a2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt @@ -7,6 +7,7 @@ import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.request.LanguageRequest +import io.tolgee.dtos.request.language.LanguageFilters import io.tolgee.exceptions.NotFoundException import io.tolgee.model.Language import io.tolgee.model.Language.Companion.fromRequestDTO @@ -338,8 +339,9 @@ class LanguageService( fun getPaged( projectId: Long, pageable: Pageable, + filters: LanguageFilters?, ): Page { - return this.languageRepository.findAllByProjectId(projectId, pageable) + return this.languageRepository.findAllByProjectId(projectId, pageable, filters ?: LanguageFilters()) } fun findByIdIn(ids: Iterable): List { diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index 797030d939..6e05e1bbc0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -9,6 +9,7 @@ import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.cacheable.ProjectDto import io.tolgee.dtos.request.project.CreateProjectRequest import io.tolgee.dtos.request.project.EditProjectRequest +import io.tolgee.dtos.request.project.ProjectFilters import io.tolgee.dtos.response.ProjectDTO import io.tolgee.dtos.response.ProjectDTO.Companion.fromEntityAndPermission import io.tolgee.exceptions.BadRequestException @@ -259,8 +260,13 @@ class ProjectService( @CacheEvict(cacheNames = [Caches.PROJECTS], key = "#id") fun deleteProject(id: Long) { val project = get(id) - languageService.evictCacheForProject(project.id) - project.deletedAt = currentDateProvider.date + val languages = project.languages + val currentDate = currentDateProvider.date + languages.forEach { + it.deletedAt = currentDate + } + languageService.saveAll(languages) + project.deletedAt = currentDate save(project) } @@ -364,12 +370,14 @@ class ProjectService( pageable: Pageable, search: String?, organizationId: Long? = null, + filters: ProjectFilters? = null, ): Page { return findPermittedInOrganizationPaged( pageable = pageable, search = search, organizationId = organizationId, userAccountId = authenticationFacade.authenticatedUser.id, + filters = filters, ) } @@ -378,6 +386,7 @@ class ProjectService( search: String?, organizationId: Long? = null, userAccountId: Long, + filters: ProjectFilters? = ProjectFilters(), ): Page { val withoutPermittedLanguages = projectRepository.findAllPermitted( @@ -385,6 +394,7 @@ class ProjectService( pageable, search, organizationId, + filters ?: ProjectFilters(), ) return addPermittedLanguagesToProjects(withoutPermittedLanguages, userAccountId) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt index 73f543ffcf..a8e8b4b479 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt @@ -20,6 +20,7 @@ import io.tolgee.model.translation.TranslationComment_ import io.tolgee.model.translation.Translation_ import io.tolgee.model.views.KeyWithTranslationsView import io.tolgee.model.views.TranslationView +import io.tolgee.security.authentication.AuthenticationFacade import jakarta.persistence.EntityManager import jakarta.persistence.criteria.CriteriaBuilder import jakarta.persistence.criteria.CriteriaQuery @@ -39,6 +40,7 @@ class QueryBase( params: TranslationFilters, private var isKeyIdsQuery: Boolean = false, private val entityManager: EntityManager, + private val authenticationFacade: AuthenticationFacade, ) { val whereConditions: MutableSet = HashSet() val root: Root = query.from(Key::class.java) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryGlobalFiltering.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryGlobalFiltering.kt index 091e1f6fdd..16db9e9752 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryGlobalFiltering.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryGlobalFiltering.kt @@ -9,6 +9,8 @@ import io.tolgee.model.activity.ActivityRevision_ import io.tolgee.model.key.KeyMeta_ import io.tolgee.model.key.Key_ import io.tolgee.model.key.Tag_ +import io.tolgee.model.task.TaskKey_ +import io.tolgee.model.task.Task_ import io.tolgee.model.temp.UnsuccessfulJobKey import io.tolgee.model.temp.UnsuccessfulJobKey_ import jakarta.persistence.EntityManager @@ -36,6 +38,7 @@ class QueryGlobalFiltering( filterSearch() filterRevisionId() filterFailedTargets() + filterTask() } private fun filterFailedTargets() { @@ -140,6 +143,17 @@ class QueryGlobalFiltering( } } + private fun filterTask() { + if (params.filterTaskNumber != null) { + val translationTaskJoin = + queryBase.root + .join(Key_.tasks, JoinType.LEFT) + .join(TaskKey_.task, JoinType.LEFT) + + queryBase.whereConditions.add(translationTaskJoin.get(Task_.number).`in`(params.filterTaskNumber)) + } + } + private fun filterRevisionId() { if (!params.filterRevisionId.isNullOrEmpty()) { val modifiedEntitySubquery = queryBase.query.subquery(Long::class.java) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationViewDataProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationViewDataProvider.kt index c04780f9d1..c70a17db06 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationViewDataProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationViewDataProvider.kt @@ -3,6 +3,7 @@ package io.tolgee.service.queryBuilders.translationViewBuilder import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.request.translation.TranslationFilters import io.tolgee.model.views.KeyWithTranslationsView +import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.key.TagService import io.tolgee.service.queryBuilders.CursorUtil import jakarta.persistence.EntityManager @@ -16,6 +17,7 @@ import org.springframework.stereotype.Component class TranslationViewDataProvider( private val em: EntityManager, private val tagService: TagService, + private val authenticationFacade: AuthenticationFacade, ) { fun getData( projectId: Long, @@ -98,6 +100,7 @@ class TranslationViewDataProvider( params = params, sort = Sort.by(Sort.Order.asc(KeyWithTranslationsView::keyId.name)), entityManager = em, + authenticationFacade = authenticationFacade, ) val result = em.createQuery(translationsViewQueryBuilder.keyIdsQuery).resultList deleteFailedKeysInJobTempTable() @@ -118,5 +121,6 @@ class TranslationViewDataProvider( sort = pageable.sort, cursor = cursor?.let { CursorUtil.parseCursor(it) }, entityManager = em, + authenticationFacade = authenticationFacade, ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationsViewQueryBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationsViewQueryBuilder.kt index a80373da78..99346fff40 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationsViewQueryBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/TranslationsViewQueryBuilder.kt @@ -4,6 +4,7 @@ import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.request.translation.TranslationFilters import io.tolgee.dtos.response.CursorValue import io.tolgee.model.* +import io.tolgee.security.authentication.AuthenticationFacade import jakarta.persistence.EntityManager import jakarta.persistence.criteria.* import org.hibernate.query.NullPrecedence @@ -19,6 +20,7 @@ class TranslationsViewQueryBuilder( private val sort: Sort, private val cursor: Map? = null, private val entityManager: EntityManager, + private val authenticationFacade: AuthenticationFacade, ) { private fun getBaseQuery( query: CriteriaQuery, @@ -32,6 +34,7 @@ class TranslationsViewQueryBuilder( params = params, isKeyIdsQuery = isKeyIdsQuery, entityManager, + authenticationFacade, ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index cb68c3ba26..02273b68b2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt @@ -10,12 +10,15 @@ import io.tolgee.exceptions.PermissionException import io.tolgee.model.Project import io.tolgee.model.UserAccount import io.tolgee.model.enums.Scope +import io.tolgee.model.enums.TaskType import io.tolgee.model.translation.Translation import io.tolgee.repository.KeyRepository import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.TaskService import io.tolgee.service.language.LanguageService import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service @Service @@ -34,6 +37,10 @@ class SecurityService( @set:Autowired lateinit var userAccountService: UserAccountService + @set:Autowired + @Lazy + lateinit var taskService: TaskService + fun checkAnyProjectPermission(projectId: Long) { if ( getProjectPermissionScopesNoApiKey(projectId).isNullOrEmpty() && @@ -77,6 +84,49 @@ class SecurityService( checkProjectPermission(projectId, requiredPermission, apiKey) } + fun hasTaskEditScopeOrIsAssigned( + projectId: Long, + taskNumber: Long, + ) { + try { + checkProjectPermission(projectId, Scope.TASKS_EDIT) + } catch (err: PermissionException) { + val assignees = taskService.findAssigneeById(projectId, taskNumber, activeUser.id) + if (assignees.isEmpty() || assignees[0].id != activeUser.id) { + throw err + } + } + } + + fun hasTaskViewScopeOrIsAssigned( + projectId: Long, + taskNumber: Long, + ) { + try { + checkProjectPermission(projectId, Scope.TASKS_VIEW) + } catch (err: PermissionException) { + val assignees = taskService.findAssigneeById(projectId, taskNumber, activeUser.id) + if (assignees.isEmpty() || assignees[0].id != activeUser.id) { + throw err + } + } + } + + fun translationInTask( + keyId: Long, + languageId: Long, + taskType: TaskType? = null, + ): Boolean { + val assignees = + taskService.findAssigneeByKey( + keyId, + languageId, + authenticationFacade.authenticatedUser.id, + taskType, + ) + return assignees.isNotEmpty() && assignees[0].id == activeUser.id + } + fun checkProjectPermission( projectId: Long, requiredScopes: Scope, @@ -166,21 +216,74 @@ class SecurityService( fun checkLanguageTranslatePermission( projectId: Long, languageIds: Collection, + keyId: Long? = null, ) { - checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) - checkLanguagePermission( - projectId, - ) { data -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + try { + checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) + checkLanguagePermission( + projectId, + ) { data -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + } catch (e: PermissionException) { + checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) + checkLanguagePermission( + projectId, + ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + + if (keyId != null && languageIds.isNotEmpty()) { + languageIds.forEach { + if (!translationInTask(keyId, it, TaskType.TRANSLATE)) { + throw e + } + } + } else { + throw e + } + } } fun checkLanguageStateChangePermission( projectId: Long, languageIds: Collection, + keyId: Long? = null, ) { - checkProjectPermission(projectId, Scope.TRANSLATIONS_STATE_EDIT) - checkLanguagePermission( - projectId, - ) { data -> data.checkStateChangePermitted(*languageIds.toLongArray()) } + try { + checkProjectPermission(projectId, Scope.TRANSLATIONS_STATE_EDIT) + checkLanguagePermission( + projectId, + ) { data -> data.checkStateChangePermitted(*languageIds.toLongArray()) } + } catch (e: PermissionException) { + checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) + checkLanguagePermission( + projectId, + ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + + if (keyId != null && languageIds.isNotEmpty()) { + languageIds.forEach { + if (!translationInTask(keyId, it, TaskType.REVIEW)) { + throw e + } + } + } else { + throw e + } + } + } + + fun checkScopeOrAssignedToTask( + scope: Scope, + projectId: Long, + languageId: Long, + keyId: Long, + taskType: TaskType? = null, + ) { + try { + checkProjectPermission(projectId, scope) + } catch (e: PermissionException) { + checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) + if (!translationInTask(keyId, languageId, taskType)) { + throw e + } + } } fun filterViewPermissionByTag( @@ -233,27 +336,29 @@ class SecurityService( fun checkLanguageTranslatePermission(translation: Translation) { val language = translation.language - checkLanguageTranslatePermission(language.project.id, listOf(language.id)) + checkLanguageTranslatePermission(language.project.id, listOf(language.id), translation.key.id) } fun checkStateChangePermission(translation: Translation) { val language = translation.language - checkLanguageStateChangePermission(language.project.id, listOf(language.id)) + checkLanguageStateChangePermission(language.project.id, listOf(language.id), translation.key.id) } fun checkLanguageTranslatePermissionsByTag( tags: Set, projectId: Long, + keyId: Long?, ) { val languages = languageService.findByTags(tags, projectId) - this.checkLanguageTranslatePermission(projectId, languages.map { it.id }) + this.checkLanguageTranslatePermission(projectId, languages.map { it.id }, keyId) } fun checkLanguageTranslatePermissionsByLanguageId( languageIds: Collection, projectId: Long, + keyId: Long? = null, ) { - this.checkLanguageTranslatePermission(projectId, languageIds) + this.checkLanguageTranslatePermission(projectId, languageIds, keyId) } fun checkLanguageStateChangePermissionsByTag( @@ -267,8 +372,9 @@ class SecurityService( fun checkLanguageChangeStatePermissionsByLanguageId( languageIds: Collection, projectId: Long, + keyId: Long? = null, ) { - this.checkLanguageStateChangePermission(projectId, languageIds) + this.checkLanguageStateChangePermission(projectId, languageIds, keyId) } fun checkApiKeyScopes( diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt index 3f15d8091b..5bf39ae6c4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt @@ -9,6 +9,8 @@ import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.dtos.queryResults.UserAccountView import io.tolgee.dtos.request.UserUpdatePasswordRequestDto import io.tolgee.dtos.request.UserUpdateRequestDto +import io.tolgee.dtos.request.task.UserAccountFilters +import io.tolgee.dtos.request.userAccount.UserAccountPermissionsFilters import io.tolgee.dtos.request.validators.exceptions.ValidationException import io.tolgee.events.OnUserCountChanged import io.tolgee.events.user.OnUserCreated @@ -37,6 +39,7 @@ import org.springframework.cache.annotation.Cacheable import org.springframework.context.ApplicationEventPublisher import org.springframework.context.annotation.Lazy import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service @@ -345,8 +348,15 @@ class UserAccountService( pageable: Pageable, search: String?, exceptUserId: Long? = null, + filters: UserAccountFilters? = null, ): Page { - return userAccountRepository.getAllInProject(projectId, pageable, search = search, exceptUserId) + return userAccountRepository.getAllInProject( + projectId, + pageable, + search = search, + exceptUserId, + filters ?: UserAccountFilters(), + ) } fun getAllInProjectWithPermittedLanguages( @@ -354,8 +364,9 @@ class UserAccountService( pageable: Pageable, search: String?, exceptUserId: Long? = null, + filters: UserAccountFilters? = null, ): Page { - val users = getAllInProject(projectId, pageable, search, exceptUserId) + val users = getAllInProject(projectId, pageable, search, exceptUserId, filters) val organizationBasePermission = organizationService.getProjectOwner(projectId = projectId).basePermission val permittedLanguageMap = @@ -548,4 +559,25 @@ class UserAccountService( } fun findActiveView(id: Long): UserAccountView? = userAccountRepository.findActiveView(id) + + fun findWithMinimalPermissions( + filters: UserAccountPermissionsFilters, + projectId: Long, + search: String?, + pageable: Pageable, + ): PageImpl { + val ids = + userAccountRepository.findUsersWithMinimalPermissions( + filters.filterId ?: listOf(), + filters.filterMinimalScopeExtended, + filters.filterMinimalRole, + projectId, + filters.filterViewLanguageId, + filters.filterEditLanguageId, + filters.filterStateLanguageId, + search, + pageable, + ) + return PageImpl(userAccountRepository.findAllById(ids.content), pageable, ids.totalElements) + } } diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 754bbe3507..ddd746d3be 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3440,4 +3440,80 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/testing/src/main/kotlin/io/tolgee/ProjectAuthControllerTest.kt b/backend/testing/src/main/kotlin/io/tolgee/ProjectAuthControllerTest.kt index 7f9a996647..6e84d3cbf5 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/ProjectAuthControllerTest.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/ProjectAuthControllerTest.kt @@ -39,7 +39,7 @@ abstract class ProjectAuthControllerTest( private var _projectAuthRequestPerformer: ProjectAuthRequestPerformer? = null - private var projectAuthRequestPerformer: ProjectAuthRequestPerformer + var projectAuthRequestPerformer: ProjectAuthRequestPerformer get() { return _projectAuthRequestPerformer ?: throw Exception("Method not annotated with ApiKeyAccessTestMethod nor ProjectJWTAuthTestMethod?") diff --git a/e2e/cypress/common/export.ts b/e2e/cypress/common/export.ts index 67d3ae8ffd..a315ddcf78 100644 --- a/e2e/cypress/common/export.ts +++ b/e2e/cypress/common/export.ts @@ -52,6 +52,8 @@ export const exportToggleLanguage = (lang: string) => { export function assertExportLanguagesSelected(languages: string[]) { cy.gcy('export-language-selector').click(); + cy.gcy('export-language-selector-item').should('be.visible'); + languages.forEach((language) => { cy.gcy('export-language-selector-item') .contains(language) diff --git a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.1.cy.ts index e7c49b5926..f4c951fa52 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.1.cy.ts @@ -11,6 +11,7 @@ describe('Permissions admin 1', () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': SKIP, + 'project-menu-item-tasks': SKIP, 'project-menu-item-settings': SKIP, 'project-menu-item-languages': SKIP, 'project-menu-item-members': SKIP, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts index 0e4c6b3f3a..05d7ac7643 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts @@ -11,6 +11,7 @@ describe('Permissions admin 2', () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, 'project-menu-item-translations': RUN, + 'project-menu-item-tasks': SKIP, 'project-menu-item-settings': SKIP, 'project-menu-item-languages': SKIP, 'project-menu-item-members': SKIP, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.3.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.3.cy.ts index ec4c6b35de..07c1d3b032 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.3.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.3.cy.ts @@ -11,6 +11,7 @@ describe('Permissions admin 3', () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, 'project-menu-item-translations': SKIP, + 'project-menu-item-tasks': RUN, 'project-menu-item-settings': RUN, 'project-menu-item-languages': RUN, 'project-menu-item-members': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts index 98c82e2c95..0cacce02e6 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts @@ -9,9 +9,6 @@ describe('Batch jobs permissions 1', () => { it('translations.batch-machine', () => { visitProjectWithPermissions({ scopes: ['translations.batch-machine'], - viewLanguageTags: ['en', 'cs'], - translateLanguageTags: ['cs'], - stateChangeLanguageTags: ['en'], }).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts index 75edefc6ac..9df6299dbf 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts @@ -9,9 +9,6 @@ describe('Batch jobs permissions 2', () => { it('translations.batch-by-mt', () => { visitProjectWithPermissions({ scopes: ['translations.batch-by-tm'], - viewLanguageTags: ['en', 'cs'], - translateLanguageTags: ['cs'], - stateChangeLanguageTags: ['en'], }).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts index 6289adbe6d..3bc0e0ef96 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts @@ -42,6 +42,7 @@ describe('Server admin 1', { retries: { runMode: 5 } }, () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': SKIP, + 'project-menu-item-tasks': SKIP, 'project-menu-item-settings': SKIP, 'project-menu-item-languages': SKIP, 'project-menu-item-members': SKIP, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts index b4696540d8..79f19ca774 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts @@ -14,6 +14,7 @@ describe('Server admin 2', () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, 'project-menu-item-translations': RUN, + 'project-menu-item-tasks': SKIP, 'project-menu-item-settings': SKIP, 'project-menu-item-languages': SKIP, 'project-menu-item-members': SKIP, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts index ad4e4188bf..854bd177f6 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts @@ -14,6 +14,7 @@ describe('Server admin 3', () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, 'project-menu-item-translations': SKIP, + 'project-menu-item-tasks': RUN, 'project-menu-item-settings': RUN, 'project-menu-item-languages': RUN, 'project-menu-item-members': RUN, diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 3a3081712e..ed3845752b 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -73,6 +73,7 @@ declare namespace DataCy { "api-key-list-item-regenerate-button" | "api-keys-create-edit-dialog" | "api-keys-project-select-item" | + "assignee-select" | "auto-avatar-img" | "avatar-image" | "avatar-menu-open-button" | @@ -159,7 +160,6 @@ declare namespace DataCy { "export-message-format-selector-item" | "export-namespace-selector" | "export-namespace-selector-item" | - "export-nested-selector" | "export-state-selector" | "export-state-selector-item" | "export-submit-button" | @@ -400,6 +400,7 @@ declare namespace DataCy { "project-menu-item-members" | "project-menu-item-projects" | "project-menu-item-settings" | + "project-menu-item-tasks" | "project-menu-item-translations" | "project-menu-items" | "project-mt-dialog-settings-inherited" | @@ -470,6 +471,10 @@ declare namespace DataCy { "storage-subtitle" | "tag-autocomplete-input" | "tag-autocomplete-option" | + "task-select-item" | + "task-select-search" | + "tasks-view-list-button" | + "tasks-view-table-button" | "this-is-the-element" | "top-banner" | "top-banner-content" | @@ -504,6 +509,7 @@ declare namespace DataCy { "translations-cell-save-button" | "translations-cell-screenshots-button" | "translations-cell-switch-mode" | + "translations-cell-task-button" | "translations-comments-input" | "translations-comments-load-more-button" | "translations-filter-clear-all" | @@ -548,6 +554,8 @@ declare namespace DataCy { "user-menu-theme-switch" | "user-menu-user-settings" | "user-profile" | + "user-switch-item" | + "user-switch-search" | "webhook-form-cancel" | "webhook-form-delete" | "webhook-form-save" | diff --git a/e2e/cypress/support/registerCommands.ts b/e2e/cypress/support/registerCommands.ts index 5860651597..8b0d9be4e2 100644 --- a/e2e/cypress/support/registerCommands.ts +++ b/e2e/cypress/support/registerCommands.ts @@ -129,26 +129,10 @@ export const register = () => { }); }); - Cypress.Commands.add('chooseDatePicker', (selector, value) => { - cy.get('body').then(($body) => { - const mobilePickerSelector = `${selector} input[readonly]`; - const isMobile = $body.find(mobilePickerSelector).length > 0; - if (isMobile) { - // The MobileDatePicker component has readonly inputs and needs to - // be opened and clicked on edit so its inputs can be edited - cy.get(mobilePickerSelector).click(); - cy.get( - '[role="dialog"] [aria-label="calendar view is open, go to text input view"]' - ).click(); - cy.contains('[role="dialog"] button', 'OK') - .closest('[role="dialog"]') - .find('input') - .clear() - .type(value); - cy.contains('[role="dialog"] button', 'OK').click(); - } else { - cy.get(selector).find('input').clear().type(value); - } - }); - }); + Cypress.Commands.add( + 'chooseDatePicker', + (selector: string, value: string) => { + cy.get(selector).find('input').clear().type(value); + } + ); }; diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt index fbbdede5ad..f07b8088f5 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/V2ProjectsInvitationControllerEeTest.kt @@ -37,8 +37,10 @@ class V2ProjectsInvitationControllerEeTest : ProjectAuthControllerTest("/v2/proj scopes = setOf("translations.edit") }.andIsOk - val invitation = invitationTestUtil.getInvitation(result) - invitation.permission!!.scopes.assert.containsExactlyInAnyOrder(Scope.TRANSLATIONS_EDIT) + executeInNewTransaction { + val invitation = invitationTestUtil.getInvitation(result) + invitation.permission!!.scopes.assert.containsExactlyInAnyOrder(Scope.TRANSLATIONS_EDIT) + } } @Test @@ -59,8 +61,10 @@ class V2ProjectsInvitationControllerEeTest : ProjectAuthControllerTest("/v2/proj translateLanguages = setOf(getLang("en")) }.andIsOk - val invitation = invitationTestUtil.getInvitation(result) - invitation.permission!!.translateLanguages.map { it.tag }.assert.containsExactlyInAnyOrder("en") + executeInNewTransaction { + val invitation = invitationTestUtil.getInvitation(result) + invitation.permission!!.translateLanguages.map { it.tag }.assert.containsExactlyInAnyOrder("en") + } } @Test diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 5d20f0e8e0..898a3ddac0 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -16,16 +16,16 @@ "@emotion/styled": "^11.11.0", "@formatjs/icu-messageformat-parser": "^2.0.8", "@mdx-js/rollup": "^3.0.0", - "@mui/icons-material": "^5.5.1", "@mui/lab": "^5.0.0-alpha.75", "@mui/material": "^5.5.3", - "@mui/x-date-pickers": "5.0.0-beta.6", + "@mui/x-date-pickers": "^7.9.0", "@openreplay/tracker": "^3.5.4", "@sentry/browser": "^7.80.0", "@stomp/stompjs": "^6.1.2", "@tginternal/editor": "^1.15.1", "@tolgee/format-icu": "^5.27.0", "@tolgee/react": "^5.27.0", + "@untitled-ui/icons-react": "^0.1.3", "@vitejs/plugin-react": "^4.2.1", "clsx": "^1.1.1", "codemirror": "^6.0.1", @@ -54,7 +54,7 @@ "react-list": "^0.8.17", "react-markdown": "^8.0.4", "react-qr-code": "^2.0.7", - "react-query": "^3.39.2", + "react-query": "^3.39.3", "react-router-dom": "^5.2.0", "recharts": "2.1.9", "reflect-metadata": "^0.1.13", @@ -98,7 +98,7 @@ "redux-devtools-extension": "^2.13.9", "rehype-highlight": "^7.0.0", "ts-unused-exports": "^9.0.4", - "typescript": "^5.3.3", + "typescript": "^5.5.4", "vite-plugin-node-polyfills": "^0.19.0", "vite-plugin-static-copy": "^1.0.0", "vite-plugin-svgr": "^4.2.0", @@ -466,9 +466,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz", + "integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -612,75 +612,6 @@ "w3c-keyname": "^2.2.4" } }, - "node_modules/@date-io/core": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.17.0.tgz", - "integrity": "sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==" - }, - "node_modules/@date-io/date-fns": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.17.0.tgz", - "integrity": "sha512-L0hWZ/mTpy3Gx/xXJ5tq5CzHo0L7ry6KEO9/w/JWiFWFLZgiNVo3ex92gOl3zmzjHqY/3Ev+5sehAr8UnGLEng==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "date-fns": "^2.0.0" - }, - "peerDependenciesMeta": { - "date-fns": { - "optional": true - } - } - }, - "node_modules/@date-io/dayjs": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-2.17.0.tgz", - "integrity": "sha512-Iq1wjY5XzBh0lheFA0it6Dsyv94e8mTiNR8vuTai+KopxDkreL3YjwTmZHxkgB7/vd0RMIACStzVgWvPATnDCA==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "dayjs": "^1.8.17" - }, - "peerDependenciesMeta": { - "dayjs": { - "optional": true - } - } - }, - "node_modules/@date-io/luxon": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.17.0.tgz", - "integrity": "sha512-l712Vdm/uTddD2XWt9TlQloZUiTiRQtY5TCOG45MQ/8u0tu8M17BD6QYHar/3OrnkGybALAMPzCy1r5D7+0HBg==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "luxon": "^1.21.3 || ^2.x || ^3.x" - }, - "peerDependenciesMeta": { - "luxon": { - "optional": true - } - } - }, - "node_modules/@date-io/moment": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-2.17.0.tgz", - "integrity": "sha512-e4nb4CDZU4k0WRVhz1Wvl7d+hFsedObSauDHKtZwU9kt7gdYEAzKgnrSCTHsEaXrDumdrkCYTeZ0Tmyk7uV4tw==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "moment": "^2.24.0" - }, - "peerDependenciesMeta": { - "moment": { - "optional": true - } - } - }, "node_modules/@dicebear/avatars": { "version": "4.10.2", "resolved": "https://registry.npmjs.org/@dicebear/avatars/-/avatars-4.10.2.tgz", @@ -2040,39 +1971,14 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.6.tgz", - "integrity": "sha512-0aoWS4qvk1uzm9JBs83oQmIMIQeTBUeqqu8u+3uo2tMznrB5fIKqQVCbCgq+4Tm4jG+5F7dIvnjvQ2aV7UKtdw==", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.0.tgz", + "integrity": "sha512-8SLffXYPRVpcZx5QzxNE8fytTqzp+IuU3deZbQWg/vSaTlDpR5YVrQ4qQtXTi5cRdhOufV5INylmwlKK+//nPw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, - "node_modules/@mui/icons-material": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.6.tgz", - "integrity": "sha512-GnkxMtlhs+8ieHLmCytg00ew0vMOiXGFCw8Ra9nxMsBjBqnrOI5gmXqUm+sGggeEU/HG8HyeqC1MX/IxOBJHzA==", - "dependencies": { - "@babel/runtime": "^7.23.8" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@mui/material": "^5.0.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/lab": { "version": "5.0.0-alpha.162", "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.162.tgz", @@ -2122,19 +2028,19 @@ } }, "node_modules/@mui/material": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.6.tgz", - "integrity": "sha512-rw7bDdpi2kzfmcDN78lHp8swArJ5sBCKsn+4G3IpGfu44ycyWAWX0VdlvkjcR9Yrws2KIm7c+8niXpWHUDbWoA==", - "dependencies": { - "@babel/runtime": "^7.23.8", - "@mui/base": "5.0.0-beta.33", - "@mui/core-downloads-tracker": "^5.15.6", - "@mui/system": "^5.15.6", - "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.6", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.0.tgz", + "integrity": "sha512-DbR1NckTLpjt9Zut9EGQ70th86HfN0BYQgyYro6aXQrNfjzSwe3BJS1AyBQ5mJ7TdL6YVRqohfukxj9JlqZZUg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.40", + "@mui/core-downloads-tracker": "^5.16.0", + "@mui/system": "^5.16.0", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.16.0", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^18.2.0", "react-transition-group": "^4.4.5" @@ -2165,6 +2071,37 @@ } } }, + "node_modules/@mui/material/node_modules/@mui/base": { + "version": "5.0.0-beta.40", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", + "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material/node_modules/clsx": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", @@ -2174,12 +2111,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.6.tgz", - "integrity": "sha512-ZBX9E6VNUSscUOtU8uU462VvpvBS7eFl5VfxAzTRVQBHflzL+5KtnGrebgf6Nd6cdvxa1o0OomiaxSKoN2XDmg==", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.0.tgz", + "integrity": "sha512-sYpubkO1MZOnxNyVOClrPNOTs0MfuRVVnAvCeMaOaXt6GimgQbnUcshYv2pSr6PFj+Mqzdff/FYOBceK8u5QgA==", "dependencies": { - "@babel/runtime": "^7.23.8", - "@mui/utils": "^5.15.6", + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.16.0", "prop-types": "^15.8.1" }, "engines": { @@ -2200,13 +2137,13 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.6.tgz", - "integrity": "sha512-KAn8P8xP/WigFKMlEYUpU9z2o7jJnv0BG28Qu1dhNQVutsLVIFdRf5Nb+0ijp2qgtcmygQ0FtfRuXv5LYetZTg==", + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", + "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", "dependencies": { - "@babel/runtime": "^7.23.8", + "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -2231,17 +2168,17 @@ } }, "node_modules/@mui/system": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.6.tgz", - "integrity": "sha512-J01D//u8IfXvaEHMBQX5aO2l7Q+P15nt96c4NskX7yp5/+UuZP8XCQJhtBtLuj+M2LLyXHYGmCPeblsmmscP2Q==", - "dependencies": { - "@babel/runtime": "^7.23.8", - "@mui/private-theming": "^5.15.6", - "@mui/styled-engine": "^5.15.6", - "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.6", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.0.tgz", + "integrity": "sha512-9YbkC2m3+pNumAvubYv+ijLtog6puJ0fJ6rYfzfLCM47pWrw3m+30nXNM8zMgDaKL6vpfWJcCXm+LPaWBpy7sw==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.16.0", + "@mui/styled-engine": "^5.15.14", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.16.0", "clsx": "^2.1.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -2278,9 +2215,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.13", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", - "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", + "version": "7.2.14", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", + "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0" }, @@ -2291,11 +2228,11 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.6.tgz", - "integrity": "sha512-qfEhf+zfU9aQdbzo1qrSWlbPQhH1nCgeYgwhOVnj9Bn39shJQitEnXpSQpSNag8+uty5Od6PxmlNKPTnPySRKA==", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.0.tgz", + "integrity": "sha512-kLLi5J1xY+mwtUlMb8Ubdxf4qFAA1+U7WPBvjM/qQ4CIwLCohNb0sHo1oYPufjSIH/Z9+dhVxD7dJlfGjd1AVA==", "dependencies": { - "@babel/runtime": "^7.23.8", + "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -2318,41 +2255,39 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "5.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-5.0.0-beta.6.tgz", - "integrity": "sha512-8NS3s1uslmmZLl1KVCJ6eu9Wnago0EUdRb4NTCmJOwphE2w7cdI4LP+ZGRH7uWtkb6dQEmum1oumBAB3g4Ix+A==", - "dependencies": { - "@babel/runtime": "^7.18.6", - "@date-io/core": "^2.15.0", - "@date-io/date-fns": "^2.15.0", - "@date-io/dayjs": "^2.15.0", - "@date-io/luxon": "^2.15.0", - "@date-io/moment": "^2.15.0", - "@mui/utils": "^5.4.1", - "@types/react-transition-group": "^4.4.5", - "clsx": "^1.2.1", - "prop-types": "^15.7.2", - "react-transition-group": "^4.4.5", - "rifm": "^0.12.1" + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.9.0.tgz", + "integrity": "sha512-GMDprioHlYmNle8Cbh6TxB4QThDGgqJxfH/R/p/5dNk+Tn5vB1gZSDMn3wVxItiEV6tDXbkyS5gPhSMVFDGvAA==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@mui/base": "^5.0.0-beta.40", + "@mui/system": "^5.16.0", + "@mui/utils": "^5.16.0", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", - "@mui/material": "^5.4.1", - "@mui/system": "^5.4.1", - "date-fns": "^2.25.0", + "@mui/material": "^5.15.14", + "date-fns": "^2.25.0 || ^3.2.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", "dayjs": "^1.10.7", - "luxon": "^1.28.0 || ^2.0.0 || ^3.0.0", - "moment": "^2.29.1", - "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0" + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -2364,6 +2299,9 @@ "date-fns": { "optional": true }, + "date-fns-jalali": { + "optional": true + }, "dayjs": { "optional": true }, @@ -2372,9 +2310,54 @@ }, "moment": { "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/@mui/base": { + "version": "5.0.0-beta.40", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", + "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true } } }, + "node_modules/@mui/x-date-pickers/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4195,6 +4178,20 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, + "node_modules/@untitled-ui/icons-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@untitled-ui/icons-react/-/icons-react-0.1.3.tgz", + "integrity": "sha512-IIInbrn4E+xu2iIZjBw7/ru8Q1kH8A3bakh+967gmx5GLrA5j3ypc5Jc7+5ohA3ZtYjnhHM++cLUIkwgcC8hiA==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", @@ -13001,14 +12998,6 @@ "node": ">=0.10.0" } }, - "node_modules/rifm": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.12.1.tgz", - "integrity": "sha512-OGA1Bitg/dSJtI/c4dh90svzaUPt228kzFsUkJbtA2c964IqEAwWXeL9ZJi86xWv3j5SMqRvGULl7bA6cK0Bvg==", - "peerDependencies": { - "react": ">=16.8" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -14229,9 +14218,9 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/webapp/package.json b/webapp/package.json index 2b2bcc3ccc..8070ce0a79 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -12,16 +12,16 @@ "@emotion/styled": "^11.11.0", "@formatjs/icu-messageformat-parser": "^2.0.8", "@mdx-js/rollup": "^3.0.0", - "@mui/icons-material": "^5.5.1", "@mui/lab": "^5.0.0-alpha.75", "@mui/material": "^5.5.3", - "@mui/x-date-pickers": "5.0.0-beta.6", + "@mui/x-date-pickers": "^7.9.0", "@openreplay/tracker": "^3.5.4", "@sentry/browser": "^7.80.0", "@stomp/stompjs": "^6.1.2", "@tginternal/editor": "^1.15.1", "@tolgee/format-icu": "^5.27.0", "@tolgee/react": "^5.27.0", + "@untitled-ui/icons-react": "^0.1.3", "@vitejs/plugin-react": "^4.2.1", "clsx": "^1.1.1", "codemirror": "^6.0.1", @@ -50,7 +50,7 @@ "react-list": "^0.8.17", "react-markdown": "^8.0.4", "react-qr-code": "^2.0.7", - "react-query": "^3.39.2", + "react-query": "^3.39.3", "react-router-dom": "^5.2.0", "recharts": "2.1.9", "reflect-metadata": "^0.1.13", @@ -132,7 +132,7 @@ "redux-devtools-extension": "^2.13.9", "rehype-highlight": "^7.0.0", "ts-unused-exports": "^9.0.4", - "typescript": "^5.3.3", + "typescript": "^5.5.4", "vite-plugin-node-polyfills": "^0.19.0", "vite-plugin-static-copy": "^1.0.0", "vite-plugin-svgr": "^4.2.0", diff --git a/webapp/src/component/AutoTranslationIcon.tsx b/webapp/src/component/AutoTranslationIcon.tsx index 0ea3ffb6ec..b5b1317c4b 100644 --- a/webapp/src/component/AutoTranslationIcon.tsx +++ b/webapp/src/component/AutoTranslationIcon.tsx @@ -1,9 +1,6 @@ import { styled } from '@mui/material'; import { useTranslate } from '@tolgee/react'; -import { - MachineTranslationIcon, - TranslationMemoryIcon, -} from 'tg.component/CustomIcons'; +import { Mt, TranslationMemory } from 'tg.component/CustomIcons'; import { useServiceImg } from 'tg.views/projects/translations/ToolsPanel/panels/MachineTranslation/useServiceImg'; import { TranslationFlagIcon } from './TranslationFlagIcon'; @@ -48,9 +45,9 @@ export const AutoTranslationIcon: React.FC = ({ {provider && providerImg ? ( ) : provider ? ( - + ) : ( - + )} } diff --git a/webapp/src/component/CustomIcons.tsx b/webapp/src/component/CustomIcons.tsx index 76104aaf72..43aac6eb1c 100644 --- a/webapp/src/component/CustomIcons.tsx +++ b/webapp/src/component/CustomIcons.tsx @@ -1,82 +1,16 @@ -import React, { ComponentProps } from 'react'; -import { SvgIcon } from '@mui/material'; - -import ExportSvg from '../svgs/icons/export.svg?react'; -import ImportSvg from '../svgs/icons/import.svg?react'; -import ProjectsSvg from '../svgs/icons/projects.svg?react'; -import SettingsSvg from '../svgs/icons/settings.svg?react'; -import TranslationSvg from '../svgs/icons/translation.svg?react'; -import UserAddSvg from '../svgs/icons/user-add.svg?react'; -import UserSettingSvg from '../svgs/icons/user-setting.svg?react'; -import TranslationMemorySvg from '../svgs/icons/translationMemory.svg?react'; -import MachineTranslationSvg from '../svgs/icons/machineTranslation.svg?react'; -import TadaSvg from '../svgs/icons/tada.svg?react'; -import RocketSvg from '../svgs/icons/rocket.svg?react'; -import DropZoneSvg from '../svgs/icons/dropzone.svg?react'; -import QSFinishedSvg from '../svgs/icons/qs-finished.svg?react'; -import StarsSvg from '../svgs/icons/stars.svg?react'; -import SlackSvg from '../svgs/icons/slack.svg?react'; - -type IconProps = ComponentProps; - -const CustomIcon: React.FC = ({ - icon, - ...props -}) => { - const Icon = icon; - return ( - - - - ); -}; - -export const ExportIcon: React.FC = (props) => ( - -); -export const ImportIcon: React.FC = (props) => ( - -); -export const ProjectsIcon: React.FC = (props) => ( - -); -export const SettingsIcon: React.FC = (props) => ( - -); -export const TranslationIcon: React.FC = (props) => ( - -); -export const UserAddIcon: React.FC = (props) => ( - -); -export const UserSettingIcon: React.FC = (props) => ( - -); -export const TranslationMemoryIcon: React.FC = (props) => ( - -); -export const MachineTranslationIcon: React.FC = (props) => ( - -); -export const TadaIcon: React.FC = (props) => ( - -); -export const RocketIcon: React.FC = (props) => ( - -); - -export const DropzoneIcon: React.FC = (props) => ( - -); - -export const QSFinishedIcon: React.FC = (props) => ( - -); - -export const StarsIcon: React.FC = (props) => ( - -); - -export const SlackIcon: React.FC = (props) => ( - -); +export { default as Slack } from '../svgs/icons/slack.svg?react'; +export { default as Stars } from '../svgs/icons/stars.svg?react'; +export { default as Dropzone } from '../svgs/icons/dropzone.svg?react'; +export { default as TaskDetail } from '../svgs/icons/taskDetail.svg?react'; +export { default as Integration } from '../svgs/icons/integration.svg?react'; +export { default as CheckCircleDash } from '../svgs/icons/check-circle-dash.svg?react'; +export { default as Mt } from '../svgs/icons/mt.svg?react'; +export { default as TranslationMemory } from '../svgs/icons/translation-memory.svg?react'; +export { default as RocketFilled } from '../svgs/icons/rocket-filled.svg?react'; +export { default as Tada } from '../svgs/icons/tada.svg?react'; +export { default as QsFinished } from '../svgs/icons/qs-finished.svg?react'; +export { default as GitHub } from '../svgs/icons/github.svg?react'; +export { default as ArrowDropDown } from '../svgs/icons/arrow-drop-down.svg?react'; +export { default as ArrowRight } from '../svgs/icons/arrow-right.svg?react'; +export { default as CheckBoxOutlineBlank } from '../svgs/icons/check-box-outline-blank.svg?react'; +export { default as Google } from '../svgs/icons/google.svg?react'; diff --git a/webapp/src/component/FakeInput.tsx b/webapp/src/component/FakeInput.tsx new file mode 100644 index 0000000000..08639b025c --- /dev/null +++ b/webapp/src/component/FakeInput.tsx @@ -0,0 +1,26 @@ +import { InputBaseComponentProps, styled } from '@mui/material'; +import React from 'react'; + +const StyledPlaceholder = styled('span')` + color: ${({ theme }) => theme.palette.tokens.text.tertiary}; +`; + +const StyledFakeInput = styled('div')` + padding: 8.5px 14px; + height: 23px; + box-sizing: content-box; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +`; + +export const FakeInput = React.forwardRef(function FakeInput( + { value, placeholder, ...rest }: InputBaseComponentProps, + ref +) { + return ( + + {value || {placeholder}} + + ); +}); diff --git a/webapp/src/component/GlobalErrorModal.tsx b/webapp/src/component/GlobalErrorModal.tsx index 4c75376c54..297d3e90a7 100644 --- a/webapp/src/component/GlobalErrorModal.tsx +++ b/webapp/src/component/GlobalErrorModal.tsx @@ -1,5 +1,5 @@ import { Dialog, DialogContent, IconButton, styled } from '@mui/material'; -import { Close } from '@mui/icons-material'; +import { XClose } from '@untitled-ui/icons-react'; import GlobalErrorPage from './common/GlobalErrorPage'; import { @@ -27,7 +27,7 @@ export const GlobalErrorModal = () => { - + diff --git a/webapp/src/component/HelpMenu.tsx b/webapp/src/component/HelpMenu.tsx index c7f8215c6c..956136614a 100644 --- a/webapp/src/component/HelpMenu.tsx +++ b/webapp/src/component/HelpMenu.tsx @@ -16,12 +16,11 @@ import { Button, } from '@mui/material'; import { - Email, - GitHub, - Help as HelpIcon, - MenuBook, - Message, -} from '@mui/icons-material'; + BookOpen01, + MessageSquare01, + Mail01, + HelpCircle, +} from '@untitled-ui/icons-react'; import { T, useTranslate } from '@tolgee/react'; import { @@ -29,7 +28,7 @@ import { usePreferredOrganization, useUser, } from 'tg.globalContext/helpers'; -import { SlackIcon } from './CustomIcons'; +import { GitHub, Slack } from './CustomIcons'; const BASE_URL = 'https://app.chatwoot.com'; let scriptPromise: Promise | null = null; @@ -144,7 +143,7 @@ export const HelpMenu = () => { > - + @@ -164,7 +163,7 @@ export const HelpMenu = () => { {...buttonLink('https://tolgee.io/platform')} > - + @@ -196,7 +195,7 @@ export const HelpMenu = () => { - + { {displayChat && ( - + { )} - + diff --git a/webapp/src/component/PermissionsSettings/PermissionsMenu.tsx b/webapp/src/component/PermissionsSettings/PermissionsMenu.tsx index c3d71a6b9a..e22117ec6f 100644 --- a/webapp/src/component/PermissionsSettings/PermissionsMenu.tsx +++ b/webapp/src/component/PermissionsSettings/PermissionsMenu.tsx @@ -1,6 +1,6 @@ import React, { ComponentProps, FunctionComponent } from 'react'; import { Button, Tooltip } from '@mui/material'; -import { ArrowDropDown } from '@mui/icons-material'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; import { PermissionModalProps, PermissionsModal } from './PermissionsModal'; import { useRoleTranslations } from 'tg.component/PermissionsSettings/useRoleTranslations'; diff --git a/webapp/src/component/PermissionsSettings/ScopesInfo.tsx b/webapp/src/component/PermissionsSettings/ScopesInfo.tsx index bb84d62241..fb9985298d 100644 --- a/webapp/src/component/PermissionsSettings/ScopesInfo.tsx +++ b/webapp/src/component/PermissionsSettings/ScopesInfo.tsx @@ -1,11 +1,13 @@ -import { Info } from '@mui/icons-material'; -import { styled, Tooltip } from '@mui/material'; +import { InfoCircle } from '@untitled-ui/icons-react'; +import { Box, styled, Tooltip } from '@mui/material'; import { ScopesHint } from './ScopesHint'; import { PermissionModelScope } from './types'; -const StyledInfo = styled(Info)` +const StyledInfo = styled(InfoCircle)` opacity: 0.5; + width: 22px; + height: 22px; `; type Props = { @@ -15,7 +17,9 @@ type Props = { export function ScopesInfo({ scopes }: Props) { return ( }> - + + + ); } diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 3cbe673c60..3ce867415b 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -16,6 +16,7 @@ import { HelpMenu } from './HelpMenu'; import { PublicOnlyRoute } from './common/PublicOnlyRoute'; import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; import { RootView } from 'tg.views/RootView'; +import { MyTasksView } from 'tg.views/myTasks/MyTasksView'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') @@ -117,6 +118,10 @@ export const RootRouter = () => ( + + + + diff --git a/webapp/src/component/TranslationFlagIcon.tsx b/webapp/src/component/TranslationFlagIcon.tsx index d4ce304340..350e1921e4 100644 --- a/webapp/src/component/TranslationFlagIcon.tsx +++ b/webapp/src/component/TranslationFlagIcon.tsx @@ -1,9 +1,10 @@ import { Tooltip, styled } from '@mui/material'; -const StyledImgWrapper = styled('div')` +export const StyledImgWrapper = styled('div')` display: flex; & svg { - font-size: 16px; + width: 15px; + height: 15px; } `; diff --git a/webapp/src/component/UserAccount.tsx b/webapp/src/component/UserAccount.tsx new file mode 100644 index 0000000000..6fb786b3d5 --- /dev/null +++ b/webapp/src/component/UserAccount.tsx @@ -0,0 +1,43 @@ +import { Box, styled } from '@mui/material'; +import { components } from 'tg.service/apiSchema.generated'; +import { AvatarImg } from './common/avatar/AvatarImg'; + +export type Avatar = components['schemas']['Avatar']; + +export type User = { + id: number; + username: string; + name?: string; + avatar?: Avatar; +}; + +const StyledOrgItem = styled('div')` + display: flex; + gap: 8px; + align-items: center; + text: ${({ theme }) => theme.palette.primaryText}; +`; + +type Props = { + user: User; +}; + +export const UserAccount = ({ user }: Props) => { + return ( + + + + + {user.name} + + ); +}; diff --git a/webapp/src/component/activity/ActivityCompact/ActivityCompact.tsx b/webapp/src/component/activity/ActivityCompact/ActivityCompact.tsx index a3a7de3cfa..e2c369fa84 100644 --- a/webapp/src/component/activity/ActivityCompact/ActivityCompact.tsx +++ b/webapp/src/component/activity/ActivityCompact/ActivityCompact.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { MoreVert } from '@mui/icons-material'; +import { DotsVertical } from '@untitled-ui/icons-react'; import { Box, IconButton, styled } from '@mui/material'; import { components } from 'tg.service/apiSchema.generated'; @@ -118,7 +118,7 @@ export const ActivityCompact = ({ data, diffEnabled, onDetailOpen }: Props) => { size="small" onClick={() => onDetailOpen(data)} > - + diff --git a/webapp/src/component/activity/ActivityDetail/ActivityDetailContent.tsx b/webapp/src/component/activity/ActivityDetail/ActivityDetailContent.tsx index 5fc0b115b5..eca97f80ce 100644 --- a/webapp/src/component/activity/ActivityDetail/ActivityDetailContent.tsx +++ b/webapp/src/component/activity/ActivityDetail/ActivityDetailContent.tsx @@ -9,7 +9,7 @@ import { styled, } from '@mui/material'; import { useMemo } from 'react'; -import { OpenInNew } from '@mui/icons-material'; +import { Share03 } from '@untitled-ui/icons-react'; import { T, useTranslate } from '@tolgee/react'; import { LINKS, PARAMS } from 'tg.constants/links'; import { useProject } from 'tg.hooks/useProject'; @@ -67,7 +67,7 @@ export const ActivityDetailContent = ({ href={`${LINKS.PROJECT_TRANSLATIONS.build({ [PARAMS.PROJECT_ID]: project.id, })}?activity=${data.revisionId}`} - endIcon={} + endIcon={} target="_blank" rel="noreferrer noopener" > diff --git a/webapp/src/component/activity/activityEntities.tsx b/webapp/src/component/activity/activityEntities.tsx index aa264064ef..074f20333a 100644 --- a/webapp/src/component/activity/activityEntities.tsx +++ b/webapp/src/component/activity/activityEntities.tsx @@ -380,6 +380,60 @@ export const activityEntities: Record = { }, }, }, + Task: { + label() { + return ; + }, + fields: { + name: { + type: 'text', + label() { + return ; + }, + }, + type: { + type: 'task_type', + label() { + return ; + }, + }, + state: { + type: 'task_state', + label() { + return ; + }, + }, + description: { + type: 'text', + label() { + return ; + }, + }, + dueDate: { + type: 'date', + label() { + return ; + }, + }, + }, + references: (props) => { + const result: Reference[] = []; + const name = props.description?.name ?? props.modifications?.name?.new; + const taskType = + props.description?.type ?? props.modifications?.description?.new; + const number = + props.description?.number ?? props.modifications?.number?.new; + if (name && taskType && number) { + result.push({ + type: 'task', + taskType: taskType as any, + name: name as unknown as string, + number: Number(number), + }); + } + return result; + }, + }, }; const getKeyWithLanguages = (relations: any): KeyReferenceData | undefined => { diff --git a/webapp/src/component/activity/configuration.tsx b/webapp/src/component/activity/configuration.tsx index 552874fb8b..c0f468f6d4 100644 --- a/webapp/src/component/activity/configuration.tsx +++ b/webapp/src/component/activity/configuration.tsx @@ -327,4 +327,60 @@ export const actionsConfiguration: Partial< return ; }, }, + TASK_CREATE: { + label() { + return ; + }, + entities: { + Task: true, + }, + }, + TASKS_CREATE: { + label() { + return ; + }, + entities: { + Task: true, + }, + }, + TASK_UPDATE: { + label() { + return ; + }, + entities: { + Task: true, + }, + }, + TASK_FINISH: { + label() { + return ; + }, + entities: { + Task: [], + }, + }, + TASK_CLOSE: { + label() { + return ; + }, + entities: { + Task: [], + }, + }, + TASK_REOPEN: { + label() { + return ; + }, + entities: { + Task: [], + }, + }, + TASK_KEYS_UPDATE: { + label() { + return ; + }, + entities: { + Task: [], + }, + }, }; diff --git a/webapp/src/component/activity/formatTools.tsx b/webapp/src/component/activity/formatTools.tsx index 80f400e8b0..1645aa0b3a 100644 --- a/webapp/src/component/activity/formatTools.tsx +++ b/webapp/src/component/activity/formatTools.tsx @@ -16,6 +16,9 @@ import { getBatchKeyTagListChange } from './types/getBatchKeyTagListChange'; import { getBatchNamespaceChange } from './types/getBatchNamespaceChange'; import { getBatchStateChange } from './types/getBatchStateChange'; import { getDefaultNamespaceChange } from './types/getDefaultNamespaceChange'; +import { getDateChange } from './types/getDateChange'; +import { getTaskStateChange } from './types/getTaskStateChange'; +import { getTaskTypeChange } from './types/getTaskTypeChange'; type Props = { value: DiffValue; @@ -63,6 +66,12 @@ export const formatDiff = ({ return getBatchStateChange(value); case 'default_namespace': return getDefaultNamespaceChange(value); + case 'date': + return getDateChange(value); + case 'task_state': + return getTaskStateChange(value); + case 'task_type': + return getTaskTypeChange(value); default: return diffEnabled ? getGeneralChange(value) : getNoDiffChange(value); } diff --git a/webapp/src/component/activity/references/AnyReference.tsx b/webapp/src/component/activity/references/AnyReference.tsx index 5610eb0e4e..295d71f664 100644 --- a/webapp/src/component/activity/references/AnyReference.tsx +++ b/webapp/src/component/activity/references/AnyReference.tsx @@ -7,6 +7,7 @@ import { LanguageReference } from './LanguageReference'; import { ContentDeliveryReference } from './ContentDeliveryReference'; import { ContentStorageReference } from './ContentStorageReference'; import { WebhookConfigReference } from './WebhookConfigReference'; +import { TaskReference } from './TaskReference'; const StyledReferences = styled(Box)` display: flex; @@ -78,6 +79,8 @@ const getReference = (reference: Reference) => { return ; case 'webhook_config': return ; + case 'task': + return ; default: return null; } diff --git a/webapp/src/component/activity/references/TaskReference.tsx b/webapp/src/component/activity/references/TaskReference.tsx new file mode 100644 index 0000000000..7e4d1f29b4 --- /dev/null +++ b/webapp/src/component/activity/references/TaskReference.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { styled } from '@mui/material'; + +import { TaskReferenceData } from '../types'; +import { getTaskRedirect } from 'tg.component/task/utils'; +import { useProject } from 'tg.hooks/useProject'; +import { TaskTooltip } from 'tg.component/task/TaskTooltip'; + +const StyledId = styled('span')` + font-size: 15px; +`; + +type Props = { + data: TaskReferenceData; +}; + +export const TaskReference: React.FC = ({ data }) => { + const project = useProject(); + + return ( + + + {data.name} + #{data.number} + + + ); +}; diff --git a/webapp/src/component/activity/types.tsx b/webapp/src/component/activity/types.tsx index 752e0bee92..fa890e5909 100644 --- a/webapp/src/component/activity/types.tsx +++ b/webapp/src/component/activity/types.tsx @@ -8,6 +8,8 @@ export type ActionType = components['schemas']['ProjectActivityModel']['type']; export type ProjectActivityModel = components['schemas']['ProjectActivityModel']; +type TaskModel = components['schemas']['TaskModel']; + export type DiffValue = { old?: T; new?: T; @@ -45,7 +47,8 @@ export type EntityEnum = | 'Params' | 'ContentDeliveryConfig' | 'WebhookConfig' - | 'ContentStorage'; + | 'ContentStorage' + | 'Task'; export type FieldTypeEnum = | 'text' @@ -65,7 +68,10 @@ export type FieldTypeEnum = | 'batch_translation_state' | 'batch_boolean' | 'state_array' - | 'language_tags'; + | 'language_tags' + | 'date' + | 'task_state' + | 'task_type'; export type FieldOptionsObj = { label?: (params?: TranslateParams) => React.ReactElement; @@ -115,13 +121,21 @@ export type WebhookConfigReferenceData = { url: string; }; +export type TaskReferenceData = { + type: 'task'; + name: string; + taskType: TaskModel['type']; + number: number; +}; + export type Reference = | KeyReferenceData | LanguageReferenceData | CommentReferenceData | ContentDeliveryConfigReferenceData | ContentStorageReferenceData - | WebhookConfigReferenceData; + | WebhookConfigReferenceData + | TaskReferenceData; export type ReferenceBuilder = ( data: ModifiedEntityModel diff --git a/webapp/src/component/activity/types/getCommentStateChange.tsx b/webapp/src/component/activity/types/getCommentStateChange.tsx index 9e67a94a23..846c185b62 100644 --- a/webapp/src/component/activity/types/getCommentStateChange.tsx +++ b/webapp/src/component/activity/types/getCommentStateChange.tsx @@ -1,6 +1,6 @@ import { styled } from '@mui/material'; import { T } from '@tolgee/react'; -import { Check, Clear } from '@mui/icons-material'; +import { Check, XClose } from '@untitled-ui/icons-react'; import { DiffValue } from '../types'; @@ -42,7 +42,7 @@ export const getValue = (value: string, type: 'removed' | 'added') => { } else { return ( - +
diff --git a/webapp/src/component/activity/types/getDateChange.tsx b/webapp/src/component/activity/types/getDateChange.tsx new file mode 100644 index 0000000000..395d300200 --- /dev/null +++ b/webapp/src/component/activity/types/getDateChange.tsx @@ -0,0 +1,53 @@ +import { styled } from '@mui/material'; +import { DiffValue } from '../types'; +import { useDateFormatter } from 'tg.hooks/useLocale'; + +const StyledDiff = styled('span')` + word-break: break-word; +`; + +const StyledRemoved = styled('span')` + text-decoration: line-through; +`; + +const StyledArrow = styled('span')` + padding: 0px 6px; +`; + +type Props = { + timestamp: number; +}; + +const DateChange = ({ timestamp }: Props) => { + const formatDate = useDateFormatter(); + return <>{formatDate(timestamp)}; +}; + +export const getDateChange = (input?: DiffValue) => { + if (input?.new && input?.old) { + return ( + + + + + + + + + + ); + } + if (input?.new) { + return ( + + + + ); + } else if (input?.old) { + return ( + + + + ); + } +}; diff --git a/webapp/src/component/activity/types/getTaskStateChange.tsx b/webapp/src/component/activity/types/getTaskStateChange.tsx new file mode 100644 index 0000000000..a5806222d6 --- /dev/null +++ b/webapp/src/component/activity/types/getTaskStateChange.tsx @@ -0,0 +1,47 @@ +import { styled } from '@mui/material'; +import { DiffValue } from '../types'; +import { components } from 'tg.service/apiSchema.generated'; +import { TaskState } from 'tg.component/task/TaskState'; + +type TaskState = components['schemas']['TaskModel']['state']; + +const StyledDiff = styled('span')` + word-break: break-word; +`; + +const StyledRemoved = styled('span')` + text-decoration: line-through; +`; + +const StyledArrow = styled('span')` + padding: 0px 6px; +`; + +export const getTaskStateChange = (input?: DiffValue) => { + if (input?.new && input?.old) { + return ( + + + + + + + + + + ); + } + if (input?.new) { + return ( + + + + ); + } else if (input?.old) { + return ( + + + + ); + } +}; diff --git a/webapp/src/component/activity/types/getTaskTypeChange.tsx b/webapp/src/component/activity/types/getTaskTypeChange.tsx new file mode 100644 index 0000000000..f30ec10461 --- /dev/null +++ b/webapp/src/component/activity/types/getTaskTypeChange.tsx @@ -0,0 +1,47 @@ +import { styled } from '@mui/material'; +import { DiffValue } from '../types'; +import { components } from 'tg.service/apiSchema.generated'; +import { TaskTypeChip } from 'tg.component/task/TaskTypeChip'; + +type Type = components['schemas']['TaskModel']['type']; + +const StyledDiff = styled('span')` + word-break: break-word; +`; + +const StyledRemoved = styled('span')` + text-decoration: line-through; +`; + +const StyledArrow = styled('span')` + padding: 0px 6px; +`; + +export const getTaskTypeChange = (input?: DiffValue) => { + if (input?.new && input?.old) { + return ( + + + + + + + + + + ); + } + if (input?.new) { + return ( + + + + ); + } else if (input?.old) { + return ( + + + + ); + } +}; diff --git a/webapp/src/component/billing/Plan/ShowAllFeatures.tsx b/webapp/src/component/billing/Plan/ShowAllFeatures.tsx index 9fec906401..a8c5faf804 100644 --- a/webapp/src/component/billing/Plan/ShowAllFeatures.tsx +++ b/webapp/src/component/billing/Plan/ShowAllFeatures.tsx @@ -1,5 +1,5 @@ import { useTranslate } from '@tolgee/react'; -import { OpenInNew } from '@mui/icons-material'; +import { Share04 } from '@untitled-ui/icons-react'; import { Box, Link, SxProps, css, styled } from '@mui/material'; const buttonStyle = css` @@ -21,8 +21,9 @@ const StyledButton = styled(Box)` color: ${({ theme }) => theme.palette.text.secondary}; `; -const StyledIcon = styled(OpenInNew)` - font-size: 15px; +const StyledIcon = styled(Share04)` + width: 15px; + height: 15px; position: relative; top: 2px; `; diff --git a/webapp/src/component/billing/PlanFeature.tsx b/webapp/src/component/billing/PlanFeature.tsx index 206af5ea5c..8a685f8ab8 100644 --- a/webapp/src/component/billing/PlanFeature.tsx +++ b/webapp/src/component/billing/PlanFeature.tsx @@ -1,4 +1,4 @@ -import { Check } from '@mui/icons-material'; +import { Check } from '@untitled-ui/icons-react'; import { Box, SxProps, Tooltip, Typography } from '@mui/material'; import React from 'react'; import { StyledBillingLink } from 'tg.component/billing/Decorations'; diff --git a/webapp/src/component/common/ClipboardCopyInput.tsx b/webapp/src/component/common/ClipboardCopyInput.tsx index ae1accc60b..94d7b54a02 100644 --- a/webapp/src/component/common/ClipboardCopyInput.tsx +++ b/webapp/src/component/common/ClipboardCopyInput.tsx @@ -6,7 +6,7 @@ import { } from '@mui/material'; import { T } from '@tolgee/react'; import copy from 'copy-to-clipboard'; -import { ContentCopy } from '@mui/icons-material'; +import { Copy06 } from '@untitled-ui/icons-react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; @@ -32,7 +32,7 @@ export const ClipboardCopyInput = ({ value, inputProps }: Props) => { messaging.success(); }} > - + } diff --git a/webapp/src/component/common/LabelHint.tsx b/webapp/src/component/common/LabelHint.tsx index a6463e7139..f2f820ece5 100644 --- a/webapp/src/component/common/LabelHint.tsx +++ b/webapp/src/component/common/LabelHint.tsx @@ -1,4 +1,4 @@ -import { Help } from '@mui/icons-material'; +import { HelpCircle } from '@untitled-ui/icons-react'; import { Tooltip, styled } from '@mui/material'; const StyledLabelBody = styled('div')` @@ -18,7 +18,7 @@ export const LabelHint = ({ children, title, size = 15 }: Props) => { {children} - + ); diff --git a/webapp/src/component/common/Select.tsx b/webapp/src/component/common/Select.tsx new file mode 100644 index 0000000000..100897beda --- /dev/null +++ b/webapp/src/component/common/Select.tsx @@ -0,0 +1,46 @@ +import { FunctionComponent, ComponentProps } from 'react'; +import { + Box, + FormHelperText, + Select as MUISelect, + useTheme, +} from '@mui/material'; +import { + StyledContainer, + StyledInputLabel, +} from 'tg.component/common/TextField'; + +type Props = Omit>, 'error'> & { + minHeight?: boolean; + error?: string; +}; + +export const Select: FunctionComponent = (props) => { + const theme = useTheme(); + const { label, minHeight = true, sx, error, ...otherProps } = props; + + return ( + + {label && {label}} + + + {props.children} + + {error && ( + + {error} + + )} + + + ); +}; diff --git a/webapp/src/component/common/TextField.tsx b/webapp/src/component/common/TextField.tsx index cd86f8639c..d6ec5e6a5d 100644 --- a/webapp/src/component/common/TextField.tsx +++ b/webapp/src/component/common/TextField.tsx @@ -1,19 +1,15 @@ import { FunctionComponent } from 'react'; -import { - InputLabel, - TextField as MUITextField, - styled, - textFieldClasses, -} from '@mui/material'; +import { InputLabel, TextField as MUITextField, styled } from '@mui/material'; +import React from 'react'; -const StyledContainer = styled('div')` +export const StyledContainer = styled('div')` display: grid; - .${textFieldClasses.root} { - margin-top: 4px; + label { + margin-bottom: 4px; } `; -const StyledInputLabel = styled(InputLabel)` +export const StyledInputLabel = styled(InputLabel)` font-size: 14px; font-weight: 500px; `; @@ -22,17 +18,20 @@ export type TextFieldProps = React.ComponentProps & { minHeight?: boolean; }; -export const TextField: FunctionComponent = (props) => { - const { label, minHeight = true, sx, ...otherProps } = props; - return ( - - {label && {label}} - - - ); -}; +export const TextField: FunctionComponent = React.forwardRef( + function TextField(props, ref) { + const { label, minHeight = true, sx, ...otherProps } = props; + return ( + + {label && {label}} + + + ); + } +); diff --git a/webapp/src/component/common/avatar/ProfileAvatar.tsx b/webapp/src/component/common/avatar/ProfileAvatar.tsx index 7fc24beedd..c901e70824 100644 --- a/webapp/src/component/common/avatar/ProfileAvatar.tsx +++ b/webapp/src/component/common/avatar/ProfileAvatar.tsx @@ -1,6 +1,6 @@ import { Box, IconButton, styled } from '@mui/material'; import React, { createRef, FC, useRef, useState } from 'react'; -import EditIcon from '@mui/icons-material/Edit'; +import { Edit02 } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { ReactCropperElement } from 'react-cropper'; import { messageService } from 'tg.service/MessageService'; @@ -136,7 +136,7 @@ export const ProfileAvatar: FC<{ ref={editAvatarRef as any} className="button" > - + )} diff --git a/webapp/src/component/common/buttons/SettingsIconButton.tsx b/webapp/src/component/common/buttons/SettingsIconButton.tsx index cc8d561c32..6a3e83af48 100644 --- a/webapp/src/component/common/buttons/SettingsIconButton.tsx +++ b/webapp/src/component/common/buttons/SettingsIconButton.tsx @@ -1,10 +1,10 @@ import IconButton, { IconButtonProps } from '@mui/material/IconButton'; -import SettingsIcon from '@mui/icons-material/Settings'; +import { Settings01 } from '@untitled-ui/icons-react'; export function SettingsIconButton(props: IconButtonProps) { return ( - + ); } diff --git a/webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx b/webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx index f66e83306f..1d59cc1f34 100644 --- a/webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx +++ b/webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx @@ -7,7 +7,7 @@ import { styled, Tooltip, } from '@mui/material'; -import { HelpOutline } from '@mui/icons-material'; +import { HelpCircle } from '@untitled-ui/icons-react'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; export type LoadingCheckboxWithSkeletonProps = { @@ -27,9 +27,9 @@ const StyledLabel = styled('div')` align-items: center; `; -const StyledHelpIcon = styled(HelpOutline)` +const StyledHelpIcon = styled(HelpCircle)` color: ${({ theme }) => theme.palette.tokens.icon.primary}; - font-size: 16px; + width: 16px; `; export const LoadingCheckboxWithSkeleton: FC< diff --git a/webapp/src/component/common/form/PluralFormCheckbox.tsx b/webapp/src/component/common/form/PluralFormCheckbox.tsx index 090fa08c30..bbb78023c2 100644 --- a/webapp/src/component/common/form/PluralFormCheckbox.tsx +++ b/webapp/src/component/common/form/PluralFormCheckbox.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import { Field, useFormikContext } from 'formik'; -import { ExpandLess, ExpandMore } from '@mui/icons-material'; import { Box, Checkbox, @@ -13,6 +12,7 @@ import { T, useTranslate } from '@tolgee/react'; import { FieldError, FieldLabel } from 'tg.component/FormField'; import { LabelHint } from '../LabelHint'; +import { ChevronDown, ChevronUp } from '@untitled-ui/icons-react'; function isParameterDefault(value: string | undefined) { return value === undefined || value === 'value'; @@ -62,11 +62,7 @@ export const PluralFormCheckbox = ({ disabled={!isPlural} data-cy="key-plural-checkbox-expand" > - {expanded ? ( - - ) : ( - - )} + {expanded ? : } )} diff --git a/webapp/src/component/common/form/epirationField/ExpirationDateField.tsx b/webapp/src/component/common/form/epirationField/ExpirationDateField.tsx index 6ef9a705e2..882a1211c6 100644 --- a/webapp/src/component/common/form/epirationField/ExpirationDateField.tsx +++ b/webapp/src/component/common/form/epirationField/ExpirationDateField.tsx @@ -1,5 +1,5 @@ import { useField } from 'formik'; -import { default as React, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Box, FormControl, @@ -7,6 +7,7 @@ import { MenuItem, Select, TextField as MuiTextField, + useTheme, } from '@mui/material'; import { T } from '@tolgee/react'; import { DatePicker } from '@mui/x-date-pickers'; @@ -18,6 +19,7 @@ export const ExpirationDateField = ({ options: ExpirationDateOptions; }) => { const [input, _, helpers] = useField('expiresAt'); + const theme = useTheme(); const getInitialSelectValue = () => options.find((o) => o.time === input.value)?.value || 'custom'; @@ -72,7 +74,8 @@ export const ExpirationDateField = ({ helpers.setValue(new Date(newValue).getTime()); } }} - renderInput={(params) => } + slots={{ textField: MuiTextField }} + desktopModeMediaQuery={theme.breakpoints.up('md')} /> )} diff --git a/webapp/src/component/common/form/fields/SearchField.tsx b/webapp/src/component/common/form/fields/SearchField.tsx index 4f7cc51ac4..06d77e9c7f 100644 --- a/webapp/src/component/common/form/fields/SearchField.tsx +++ b/webapp/src/component/common/form/fields/SearchField.tsx @@ -1,6 +1,6 @@ import React, { ComponentProps, useEffect, useState } from 'react'; import { IconButton, InputAdornment, TextField, useTheme } from '@mui/material'; -import { Search, Clear } from '@mui/icons-material'; +import { SearchSm, XClose } from '@untitled-ui/icons-react'; import { useTranslate } from '@tolgee/react'; import { useDebounce } from 'use-debounce'; import { stopAndPrevent } from 'tg.fixtures/eventHandler'; @@ -31,7 +31,7 @@ const SearchField = ( InputProps={{ startAdornment: ( - + ), endAdornment: Boolean(search) && ( @@ -45,7 +45,7 @@ const SearchField = ( onMouseDown={stopAndPrevent()} edge="start" > - + ), diff --git a/webapp/src/component/common/form/fields/Select.tsx b/webapp/src/component/common/form/fields/Select.tsx index a1c478f391..3801d682f5 100644 --- a/webapp/src/component/common/form/fields/Select.tsx +++ b/webapp/src/component/common/form/fields/Select.tsx @@ -1,12 +1,5 @@ -import { default as React, FunctionComponent, ReactNode } from 'react'; -import { - FormControl, - FormControlProps, - FormHelperText, - InputLabel, - Select as MUISelect, - styled, -} from '@mui/material'; +import { FunctionComponent, ReactNode, ComponentProps } from 'react'; +import { Select as TolgeeSelect } from 'tg.component/common/Select'; import { useField } from 'formik'; interface PGSelectProps { @@ -16,50 +9,29 @@ interface PGSelectProps { displayEmpty?: boolean; } -type Props = PGSelectProps & FormControlProps; - -const StyledFormControl = styled(FormControl)` - margin-top: ${({ theme }) => theme.spacing(2)}; - margin-bottom: ${({ theme }) => theme.spacing(2)}; - min-width: 120px; -`; +type Props = PGSelectProps & Partial>; export const Select: FunctionComponent = (props) => { - const [field, meta, helpers] = useField(props.name); + const [field, meta] = useField(props.name); - const { renderValue, displayEmpty, ...formControlProps } = props; + const { renderValue, displayEmpty, label, ...formControlProps } = props; return ( - value as ReactNode + } {...formControlProps} > - {props.label && ( - - {props.label} - - )} - helpers.setValue(e.target.value)} - displayEmpty={displayEmpty} - renderValue={ - typeof renderValue === 'function' - ? renderValue - : (value) => value as ReactNode - } - > - {props.children} - - {meta.error && {meta.error}} - + {props.children} + ); }; diff --git a/webapp/src/component/common/list/PaginatedHateoasList.tsx b/webapp/src/component/common/list/PaginatedHateoasList.tsx index 945359a07a..c19ef1e6fd 100644 --- a/webapp/src/component/common/list/PaginatedHateoasList.tsx +++ b/webapp/src/component/common/list/PaginatedHateoasList.tsx @@ -32,6 +32,7 @@ export const PaginatedHateoasList = < >( props: { renderItem: (itemData: TItem) => ReactNode; + itemSeparator?: () => ReactNode; loadable: UseQueryResult; title?: ReactNode; sortBy?: string[]; @@ -39,6 +40,7 @@ export const PaginatedHateoasList = < onSearchChange?: (value: string) => void; onPageChange?: (value: number) => void; emptyPlaceholder?: React.ReactNode; + getKey?: (value: TItem) => any; } & OverridableListWrappers ) => { const { loadable } = props; @@ -111,10 +113,12 @@ export const PaginatedHateoasList = < } data={items} renderItem={props.renderItem} + itemSeparator={props.itemSeparator} wrapperComponent={props.wrapperComponent} wrapperComponentProps={props.wrapperComponentProps} listComponent={props.listComponent} listComponentProps={props.listComponentProps} + getKey={props.getKey} /> ) : ( !loadable.isLoading && diff --git a/webapp/src/component/common/list/SimpleList.tsx b/webapp/src/component/common/list/SimpleList.tsx index acf9e83688..3ddc0086f6 100644 --- a/webapp/src/component/common/list/SimpleList.tsx +++ b/webapp/src/component/common/list/SimpleList.tsx @@ -33,6 +33,8 @@ export const SimpleList = < onPageChange: (page: number) => void; }; renderItem: (item: DataItem) => ReactNode; + itemSeparator?: () => ReactNode; + getKey?: (item: DataItem) => any; } & OverridableListWrappers ) => { const { data, pagination } = props; @@ -48,8 +50,11 @@ export const SimpleList = < {data.map((item, index) => ( - + {props.renderItem(item)} + {index < data.length - 1 && props.itemSeparator?.()} ))} diff --git a/webapp/src/component/key/SvgKeys.tsx b/webapp/src/component/key/SvgKeys.tsx index ab7adc34ef..4d83350140 100644 --- a/webapp/src/component/key/SvgKeys.tsx +++ b/webapp/src/component/key/SvgKeys.tsx @@ -1,9 +1,10 @@ import { - ArrowUpward, - ArrowDownward, - ArrowBack, - ArrowForward, -} from '@mui/icons-material'; + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + CornerDownLeft, +} from '@untitled-ui/icons-react'; interface CommandIconProps { children: React.ReactNode; @@ -27,20 +28,6 @@ export const KeyCtrl = () => ( ); -export const KeyEnter = () => ( - - - - - -); - export const KeyShift = () => ( ( ); -export const KeyUp = () => ; +export const KeyEnter = () => ; + +export const KeyUp = () => ; -export const KeyDown = () => ; +export const KeyDown = () => ; -export const KeyLeft = () => ; +export const KeyLeft = () => ; -export const KeyRight = () => ; +export const KeyRight = () => ; diff --git a/webapp/src/component/languages/FlagSelector/FlagSelector.tsx b/webapp/src/component/languages/FlagSelector/FlagSelector.tsx index cf10524f69..672e3b74e1 100644 --- a/webapp/src/component/languages/FlagSelector/FlagSelector.tsx +++ b/webapp/src/component/languages/FlagSelector/FlagSelector.tsx @@ -1,12 +1,12 @@ import { FunctionComponent, useMemo, useState } from 'react'; import { Button, Popover, styled } from '@mui/material'; -import { ArrowDropDown } from '@mui/icons-material'; import { supportedFlags } from '@tginternal/language-util'; import { useField } from 'formik'; import countryFlagEmoji from 'country-flag-emoji'; import { FlagImage } from '../FlagImage'; import { FlagInfo } from './types'; import { FlagSelectorContent } from './FlagSelectorContent'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; const FLAGS_INFO: FlagInfo[] = [ { code: 'empty', emoji: '🏳️', name: 'No flag' }, diff --git a/webapp/src/component/languages/LanguageAutocomplete.tsx b/webapp/src/component/languages/LanguageAutocomplete.tsx index fcf90ede24..30b43cb423 100644 --- a/webapp/src/component/languages/LanguageAutocomplete.tsx +++ b/webapp/src/component/languages/LanguageAutocomplete.tsx @@ -6,7 +6,7 @@ import { TextField, styled, } from '@mui/material'; -import { Add, Clear, Search } from '@mui/icons-material'; +import { Plus, XClose, SearchSm } from '@untitled-ui/icons-react'; import { Autocomplete } from '@mui/material'; import { suggest } from '@tginternal/language-util'; import { SuggestResult } from '@tginternal/language-util/lib/suggesting'; @@ -43,7 +43,7 @@ const getOptions = (input: string): AutocompleteOption[] => { gap={1} alignItems="center" > - + ), @@ -96,8 +96,8 @@ export const LanguageAutocomplete: FC<{ ) : ( {itemContent} @@ -121,13 +121,13 @@ export const LanguageAutocomplete: FC<{ style: { paddingRight: 0 }, startAdornment: ( - + ), endAdornment: props.onClear ? ( - + ) : undefined, diff --git a/webapp/src/component/languages/PreparedLanguage.tsx b/webapp/src/component/languages/PreparedLanguage.tsx index f9c2da3ba6..2416fb34ee 100644 --- a/webapp/src/component/languages/PreparedLanguage.tsx +++ b/webapp/src/component/languages/PreparedLanguage.tsx @@ -1,5 +1,5 @@ import { Box, IconButton, styled } from '@mui/material'; -import { Close, Edit } from '@mui/icons-material'; +import { XClose, Edit02 } from '@untitled-ui/icons-react'; import { components } from 'tg.service/apiSchema.generated'; @@ -49,14 +49,14 @@ export const PreparedLanguage: React.FC< className="editButton" onClick={props.onEdit} > - + - + diff --git a/webapp/src/component/layout/BaseView.tsx b/webapp/src/component/layout/BaseView.tsx index 721fa9cd2a..a929fdf2ea 100644 --- a/webapp/src/component/layout/BaseView.tsx +++ b/webapp/src/component/layout/BaseView.tsx @@ -16,11 +16,19 @@ const widthMap = { }; const StyledContainer = styled(Box)` - margin: 0px auto; + display: grid; width: 100%; max-width: 100%; `; +const StyledContainerInner = styled(Box)` + display: grid; + width: 100%; + margin: 0px auto; + margin-top: 0px; + margin-bottom: 0px; +`; + type BaseViewWidth = keyof typeof widthMap | number | undefined; export function getBaseViewWidth(width: BaseViewWidth) { @@ -50,6 +58,7 @@ export interface BaseViewProps { initialSearch?: string; overflow?: string; wrapperProps?: React.ComponentProps; + stretch?: boolean; } export const BaseView = (props: BaseViewProps) => { @@ -74,11 +83,15 @@ export const BaseView = (props: BaseViewProps) => { return ( - + {displayNavigation && ( { )} {displayHeader && ( - @@ -138,17 +151,24 @@ export const BaseView = (props: BaseViewProps) => { )} - + )} - - + + {!props.loading || !hideChildrenOnLoading ? ( {typeof props.children === 'function' @@ -158,7 +178,7 @@ export const BaseView = (props: BaseViewProps) => { ) : ( <> )} - + diff --git a/webapp/src/component/layout/BaseViewAddButton.tsx b/webapp/src/component/layout/BaseViewAddButton.tsx index 87cd3b1a45..1a68d91661 100644 --- a/webapp/src/component/layout/BaseViewAddButton.tsx +++ b/webapp/src/component/layout/BaseViewAddButton.tsx @@ -1,6 +1,6 @@ import { Button } from '@mui/material'; import { Link } from 'react-router-dom'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; export const BaseViewAddButton = (props: { @@ -12,7 +12,7 @@ export const BaseViewAddButton = (props: { data-cy="global-plus-button" component={props.addLinkTo ? Link : Button} to={props.addLinkTo} - startIcon={} + startIcon={} color="primary" variant="contained" aria-label="add" diff --git a/webapp/src/component/layout/DashboardPage.tsx b/webapp/src/component/layout/DashboardPage.tsx index 1e7b5546eb..9da909544b 100644 --- a/webapp/src/component/layout/DashboardPage.tsx +++ b/webapp/src/component/layout/DashboardPage.tsx @@ -13,10 +13,9 @@ import { QuickStartGuide } from './QuickStartGuide/QuickStartGuide'; import { useIsEmailVerified } from 'tg.globalContext/helpers'; const StyledMain = styled(Box)` - display: flex; - position: relative; - flex-grow: 1; - justify-content: stretch; + display: grid; + width: 100%; + min-height: 100%; container: main-container / inline-size; position: relative; `; diff --git a/webapp/src/views/projects/translations/TranslationHeader/TranslationsSearchField.tsx b/webapp/src/component/layout/HeaderSearchField.tsx similarity index 80% rename from webapp/src/views/projects/translations/TranslationHeader/TranslationsSearchField.tsx rename to webapp/src/component/layout/HeaderSearchField.tsx index cb934d68dc..3da1e3e6c6 100644 --- a/webapp/src/views/projects/translations/TranslationHeader/TranslationsSearchField.tsx +++ b/webapp/src/component/layout/HeaderSearchField.tsx @@ -1,10 +1,11 @@ import { ComponentProps } from 'react'; -import { IconButton, InputAdornment, TextField, useTheme } from '@mui/material'; -import { Search, Clear } from '@mui/icons-material'; +import { IconButton, InputAdornment, useTheme } from '@mui/material'; +import { SearchSm, XClose } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { TextField } from 'tg.component/common/TextField'; -const TranslationsSearchField = ( +export const HeaderSearchField = ( props: ComponentProps & { value: string; onSearchChange: (value: string) => void; @@ -22,7 +23,7 @@ const TranslationsSearchField = ( InputProps={{ startAdornment: ( - + ), endAdornment: Boolean(value) && ( @@ -36,7 +37,7 @@ const TranslationsSearchField = ( onMouseDown={stopAndPrevent()} edge="start" > - + ), @@ -54,5 +55,3 @@ const TranslationsSearchField = ( /> ); }; - -export default TranslationsSearchField; diff --git a/webapp/src/component/layout/QuickStartGuide/QuickStartFinishStep.tsx b/webapp/src/component/layout/QuickStartGuide/QuickStartFinishStep.tsx index d638e2e0c8..c4a65777ba 100644 --- a/webapp/src/component/layout/QuickStartGuide/QuickStartFinishStep.tsx +++ b/webapp/src/component/layout/QuickStartGuide/QuickStartFinishStep.tsx @@ -1,7 +1,7 @@ import { Box, styled, useTheme } from '@mui/material'; import { StyledLink } from './StyledComponents'; import { useGlobalActions } from 'tg.globalContext/GlobalContext'; -import { TadaIcon } from 'tg.component/CustomIcons'; +import { Tada } from 'tg.component/CustomIcons'; import { useTranslate } from '@tolgee/react'; const StyledContainer = styled(Box)` @@ -35,8 +35,10 @@ export const QuickStartFinishStep = () => { return ( - diff --git a/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx b/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx index d9993fed87..fbbc85f588 100644 --- a/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx +++ b/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx @@ -1,15 +1,16 @@ import { Box, IconButton, styled } from '@mui/material'; import { T } from '@tolgee/react'; import { useMemo } from 'react'; -import { RocketIcon } from 'tg.component/CustomIcons'; +import { ChevronUp } from '@untitled-ui/icons-react'; + import { useGlobalActions, useGlobalContext, } from 'tg.globalContext/GlobalContext'; +import { RocketFilled } from 'tg.component/CustomIcons'; import { BottomLinks } from './BottomLinks'; import { items } from './quickStartConfig'; import { QuickStartStep } from './QuickStartStep'; -import { KeyboardArrowUp } from '@mui/icons-material'; import { QuickStartFinishStep } from './QuickStartFinishStep'; const StyledContainer = styled(Box)` @@ -67,11 +68,11 @@ export const QuickStartGuide = () => { - + setQuickStartOpen(false)}> - + diff --git a/webapp/src/component/layout/QuickStartGuide/QuickStartProgress.tsx b/webapp/src/component/layout/QuickStartGuide/QuickStartProgress.tsx index 6918175e74..c0af2942fb 100644 --- a/webapp/src/component/layout/QuickStartGuide/QuickStartProgress.tsx +++ b/webapp/src/component/layout/QuickStartGuide/QuickStartProgress.tsx @@ -1,5 +1,5 @@ import { styled, useTheme } from '@mui/material'; -import { QSFinishedIcon } from 'tg.component/CustomIcons'; +import { QsFinished } from 'tg.component/CustomIcons'; const RADIUS = 45; const CIRCUIT = RADIUS * Math.PI * 2; @@ -35,9 +35,9 @@ export const QuickStartProgress = ({ percent, size = 28 }: Props) => { if (percent === 1) { return ( - ); } diff --git a/webapp/src/component/layout/QuickStartGuide/QuickStartStep.tsx b/webapp/src/component/layout/QuickStartGuide/QuickStartStep.tsx index 327f3d9a14..3f82a141c6 100644 --- a/webapp/src/component/layout/QuickStartGuide/QuickStartStep.tsx +++ b/webapp/src/component/layout/QuickStartGuide/QuickStartStep.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import { Box, styled } from '@mui/material'; import { Link, useRouteMatch } from 'react-router-dom'; import { ItemType } from './types'; -import { Check } from '@mui/icons-material'; +import { Check } from '@untitled-ui/icons-react'; import { StyledLink } from './StyledComponents'; import { useGlobalActions, diff --git a/webapp/src/component/layout/QuickStartGuide/QuickStartTopBarButton.tsx b/webapp/src/component/layout/QuickStartGuide/QuickStartTopBarButton.tsx index 871555ab30..53dc28f571 100644 --- a/webapp/src/component/layout/QuickStartGuide/QuickStartTopBarButton.tsx +++ b/webapp/src/component/layout/QuickStartGuide/QuickStartTopBarButton.tsx @@ -1,9 +1,9 @@ import { Box, Button, styled } from '@mui/material'; -import { RocketIcon } from 'tg.component/CustomIcons'; import { useGlobalActions, useGlobalContext, } from 'tg.globalContext/GlobalContext'; +import { RocketFilled } from 'tg.component/CustomIcons'; import { items } from './quickStartConfig'; import { QuickStartProgress } from './QuickStartProgress'; @@ -34,7 +34,7 @@ export const QuickStartTopBarButton = () => { color="inherit" > - + diff --git a/webapp/src/component/layout/TopBanner/Announcement.tsx b/webapp/src/component/layout/TopBanner/Announcement.tsx index 3690b5d4aa..31fc5b536e 100644 --- a/webapp/src/component/layout/TopBanner/Announcement.tsx +++ b/webapp/src/component/layout/TopBanner/Announcement.tsx @@ -1,6 +1,6 @@ import { styled } from '@mui/material'; import { useTranslate } from '@tolgee/react'; -import { TadaIcon } from 'tg.component/CustomIcons'; +import { Tada } from 'tg.component/CustomIcons'; import { BannerLink } from './BannerLink'; type Props = { @@ -36,7 +36,7 @@ export const Announcement = ({ content, link, icon, title }: Props) => { return ( - {icon ? icon : } + {icon ? icon : } {title && {title}}
{content}
diff --git a/webapp/src/component/layout/TopBanner/TopBanner.tsx b/webapp/src/component/layout/TopBanner/TopBanner.tsx index 68729a3b72..ea9ee9d933 100644 --- a/webapp/src/component/layout/TopBanner/TopBanner.tsx +++ b/webapp/src/component/layout/TopBanner/TopBanner.tsx @@ -6,7 +6,7 @@ import { } from 'tg.globalContext/GlobalContext'; import { useAnnouncement } from './useAnnouncement'; import { useIsEmailVerified } from 'tg.globalContext/helpers'; -import { Close } from '@mui/icons-material'; +import { XClose } from '@untitled-ui/icons-react'; import { useResizeObserver } from 'usehooks-ts'; import { Announcement } from 'tg.component/layout/TopBanner/Announcement'; import { useTranslate } from '@tolgee/react'; @@ -116,7 +116,7 @@ export function TopBanner() { onClick={() => dismissAnnouncement()} data-cy="top-banner-dismiss-button" > - + )}
diff --git a/webapp/src/component/navigation/Navigation.tsx b/webapp/src/component/navigation/Navigation.tsx index bb00cc03d7..ffde9689ad 100644 --- a/webapp/src/component/navigation/Navigation.tsx +++ b/webapp/src/component/navigation/Navigation.tsx @@ -7,7 +7,7 @@ import { Typography, useMediaQuery, } from '@mui/material'; -import { NavigateNext } from '@mui/icons-material'; +import { ChevronRight } from '@untitled-ui/icons-react'; import { Link as RouterLink } from 'react-router-dom'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; import { useTheme } from '@mui/material/styles'; @@ -51,7 +51,7 @@ export const Navigation: React.FC = ({ path }) => { } + separator={} itemsBeforeCollapse={0} maxItems={smallScreen ? 1 : undefined} > diff --git a/webapp/src/component/organizationSwitch/OrganizationPopover.tsx b/webapp/src/component/organizationSwitch/OrganizationPopover.tsx index a9abaeff6b..863c84c17b 100644 --- a/webapp/src/component/organizationSwitch/OrganizationPopover.tsx +++ b/webapp/src/component/organizationSwitch/OrganizationPopover.tsx @@ -11,7 +11,7 @@ import { Typography, Button, } from '@mui/material'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { useTranslate } from '@tolgee/react'; import { useDebounce } from 'use-debounce'; @@ -242,7 +242,7 @@ export const OrganizationPopover: React.FC = ({ sx={{ ml: 0.5 }} data-cy="organization-switch-new" > - + )} diff --git a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx index 943f4c39bd..ee0ccdd59d 100644 --- a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx +++ b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { Box, Link, styled } from '@mui/material'; -import { ArrowDropDown } from '@mui/icons-material'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; import { components } from 'tg.service/apiSchema.generated'; import { OrganizationItem } from './OrganizationItem'; @@ -66,7 +66,11 @@ export const OrganizationSwitch: React.FC = ({ {preferredOrganization && ( )} - + theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +type Props = { + value: Project[]; + onChange?: (projects: Project[]) => void; + label?: React.ReactNode; + sx?: SxProps; + className?: string; +}; + +export const ProjectSearchSelect: React.FC = ({ + value, + onChange, + label, + sx, + className, +}) => { + const anchorEl = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + }; + + const handleClick = () => { + setIsOpen(true); + }; + + const handleSelectOrganization = async (projects: Project[]) => { + onChange?.(projects); + setIsOpen(false); + }; + + const handleClear = () => { + onChange?.([]); + }; + + return ( + <> + + u.name).join(', ')} + data-cy="assignee-select" + minHeight={false} + label={label} + InputProps={{ + onClick: handleClick, + ref: anchorEl, + fullWidth: true, + sx: { + cursor: 'pointer', + }, + readOnly: true, + inputComponent: FakeInput, + margin: 'dense', + endAdornment: ( + + {Boolean(value.length) && ( + + + + )} + + + + + ), + }} + /> + + + + + ); +}; diff --git a/webapp/src/component/projectSearchSelect/ProjectSearchSelectItem.tsx b/webapp/src/component/projectSearchSelect/ProjectSearchSelectItem.tsx new file mode 100644 index 0000000000..138ed3dca0 --- /dev/null +++ b/webapp/src/component/projectSearchSelect/ProjectSearchSelectItem.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Box, styled } from '@mui/material'; + +import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; +import { Project } from './types'; + +const StyledOrgItem = styled('div')` + display: grid; + grid-auto-flow: column; + gap: 6px; + align-items: center; + text: ${({ theme }) => theme.palette.primaryText}; +`; + +type Props = { + data: Project; + size?: number; +}; + +export const ProjectSearchSelectItem: React.FC = ({ + data, + size = 24, +}) => { + return ( + + + + + {data.name} + + ); +}; diff --git a/webapp/src/component/projectSearchSelect/ProjectSearchSelectPopover.tsx b/webapp/src/component/projectSearchSelect/ProjectSearchSelectPopover.tsx new file mode 100644 index 0000000000..87cb033403 --- /dev/null +++ b/webapp/src/component/projectSearchSelect/ProjectSearchSelectPopover.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useState } from 'react'; +import { + MenuItem, + Popover, + Autocomplete, + InputBase, + Box, + styled, + Button, + Checkbox, + PopoverOrigin, +} from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { useDebounce } from 'use-debounce'; + +import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { ProjectSearchSelectItem } from './ProjectSearchSelectItem'; +import { Project } from './types'; + +const USERS_SEARCH_TRESHOLD = 5; + +const StyledInput = styled(InputBase)` + padding: 5px 4px 3px 16px; + flex-grow: 1; +`; + +const StyledInputWrapper = styled(Box)` + display: flex; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider1}; + padding-right: 4px; +`; + +const StyledWrapper = styled('div')` + display: grid; +`; + +const StyledProgressContainer = styled('div')` + display: flex; + align-items: center; + margin-left: -18px; +`; + +function PopperComponent(props) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { disablePortal, anchorEl, open, ...other } = props; + return ; +} + +function PaperComponent(props) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { disablePortal, anchorEl, open, ...other } = props; + return ; +} + +type Props = { + open: boolean; + onClose: () => void; + onSelect?: (value: Project[]) => void; + onSelectImmediate?: (value: Project[]) => void; + anchorEl: HTMLElement; + selected: Project[]; + ownedOnly?: boolean; + anchorOrigin?: PopoverOrigin; + transformOrigin?: PopoverOrigin; +}; + +export const ProjectSearchSelectPopover: React.FC = ({ + open, + onClose, + onSelect, + onSelectImmediate, + anchorEl, + selected, + ownedOnly, + anchorOrigin, + transformOrigin, +}) => { + const [inputValue, setInputValue] = useState(''); + const { t } = useTranslate(); + const [search] = useDebounce(inputValue, 500); + const [selection, setSelection] = useState(selected); + + useEffect(() => { + setInputValue(''); + setSelection(selected); + }, [open]); + + const query = { + params: { + filterCurrentUserOwner: Boolean(ownedOnly), + search: search || undefined, + }, + size: 20, + sort: ['name'], + }; + + const usersLoadable = useApiInfiniteQuery({ + url: '/v2/projects', + method: 'get', + query, + options: { + keepPreviousData: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + const items: Project[] = usersLoadable.data?.pages + .flatMap((page) => page._embedded?.projects) + .filter(Boolean) as Project[]; + + const [displaySearch, setDisplaySearch] = useState( + undefined + ); + + useEffect(() => { + if (usersLoadable.data && displaySearch === undefined) { + setDisplaySearch( + usersLoadable.data.pages[0].page!.totalElements! > USERS_SEARCH_TRESHOLD + ); + } + }, [usersLoadable.data]); + + if (!selected) { + return null; + } + + return ( + <> + { + onSelect?.(selection); + onClose(); + }} + anchorOrigin={ + anchorOrigin ?? { + vertical: 'top', + horizontal: 'center', + } + } + transformOrigin={ + transformOrigin ?? { + vertical: 'top', + horizontal: 'center', + } + } + > + + x} + loading={usersLoadable.isFetching} + options={items || []} + value={selection} + inputValue={inputValue} + onClose={(_, reason) => reason === 'escape' && onClose()} + clearOnEscape={false} + noOptionsText={t('global_nothing_found')} + loadingText={t('global_loading_text')} + isOptionEqualToValue={(o, v) => o.id === v.id} + onInputChange={(_, value, reason) => + reason === 'input' && setInputValue(value) + } + getOptionLabel={(u) => u.name || ''} + PopperComponent={PopperComponent} + PaperComponent={PaperComponent} + renderOption={(props, option) => { + const selected = Boolean( + selection.find((u) => u.id === option.id) + ); + return ( + + + + + + {usersLoadable.hasNextPage && + option.id === items![items!.length - 1].id && ( + + + + )} + + ); + }} + onChange={(_, newValue) => { + onSelectImmediate?.(newValue); + setSelection(newValue); + }} + renderInput={(params) => ( + + + + + ) : undefined + } + /> + + )} + /> + + + + ); +}; diff --git a/webapp/src/component/projectSearchSelect/types.ts b/webapp/src/component/projectSearchSelect/types.ts new file mode 100644 index 0000000000..7e5e2082a6 --- /dev/null +++ b/webapp/src/component/projectSearchSelect/types.ts @@ -0,0 +1,9 @@ +import { components } from 'tg.service/apiSchema.generated'; + +export type Avatar = components['schemas']['Avatar']; + +export type Project = { + id: number; + name?: string; + avatar?: Avatar; +}; diff --git a/webapp/src/component/searchSelect/SearchSelectContent.tsx b/webapp/src/component/searchSelect/SearchSelectContent.tsx index 2cf5b37d56..c9703958da 100644 --- a/webapp/src/component/searchSelect/SearchSelectContent.tsx +++ b/webapp/src/component/searchSelect/SearchSelectContent.tsx @@ -6,7 +6,7 @@ import { Tooltip, FormControl, } from '@mui/material'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { useTranslate } from '@tolgee/react'; import { SelectItem } from './SearchSelect'; @@ -106,8 +106,8 @@ export function SearchSelectContent({ PaperComponent={PaperComponent} renderOption={(props, option) => ( @@ -142,7 +142,7 @@ export function SearchSelectContent({ sx={{ ml: 0.5 }} data-cy="search-select-new" > - + )} diff --git a/webapp/src/component/searchSelect/SearchSelectMulti.tsx b/webapp/src/component/searchSelect/SearchSelectMulti.tsx index b7937ff82d..6ce1626376 100644 --- a/webapp/src/component/searchSelect/SearchSelectMulti.tsx +++ b/webapp/src/component/searchSelect/SearchSelectMulti.tsx @@ -8,7 +8,7 @@ import { FormControl, AutocompleteProps, } from '@mui/material'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { useTranslate } from '@tolgee/react'; import { SelectItem } from 'tg.component/searchSelect/SearchSelect'; @@ -92,7 +92,7 @@ export function SearchSelectMulti({ }, [items, minWidth, maxWidth]); const defaultRenderOption: RenderOption = (props, option) => ( - + ({ sx={{ ml: 0.5 }} data-cy="search-select-new" > - {actionIcon || } + {actionIcon || } )} diff --git a/webapp/src/component/security/LanguagePermissionsMenu.tsx b/webapp/src/component/security/LanguagePermissionsMenu.tsx index b29d738f41..fba8e2b881 100644 --- a/webapp/src/component/security/LanguagePermissionsMenu.tsx +++ b/webapp/src/component/security/LanguagePermissionsMenu.tsx @@ -1,6 +1,6 @@ import { ComponentProps, FunctionComponent, useRef, useState } from 'react'; import { Button, styled, Tooltip, Popover, Checkbox } from '@mui/material'; -import { ArrowDropDown, CheckBoxOutlineBlank } from '@mui/icons-material'; +import { ArrowDropDown, CheckBoxOutlineBlank } from 'tg.component/CustomIcons'; import { useTranslate } from '@tolgee/react'; import { LanguagesPermittedList } from 'tg.component/languages/LanguagesPermittedList'; @@ -140,8 +140,8 @@ export const LanguagePermissionsMenu: FunctionComponent<{ maxWidth={400} renderOption={(renderProps, option) => ( diff --git a/webapp/src/component/security/OAuthService.tsx b/webapp/src/component/security/OAuthService.tsx index 3bb5182e90..5535ce0363 100644 --- a/webapp/src/component/security/OAuthService.tsx +++ b/webapp/src/component/security/OAuthService.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import GitHubIcon from '@mui/icons-material/GitHub'; -import GoogleIcon from '@mui/icons-material/Google'; -import LoginIcon from '@mui/icons-material/Login'; +import { GitHub, Google } from 'tg.component/CustomIcons'; +import { LogIn01 } from '@untitled-ui/icons-react'; import { LINKS, PARAMS } from 'tg.constants/links'; import { T } from '@tolgee/react'; import { v4 as uuidv4 } from 'uuid'; @@ -27,7 +26,7 @@ export const gitHubService = (clientId: string): OAuthService => { authenticationUrl: encodeURI( `${GITHUB_BASE}?client_id=${clientId}&redirect_uri=${redirectUri}&scope=user:email` ), - buttonIcon: , + buttonIcon: , loginButtonTitle: , signUpButtonTitle: , }; @@ -42,7 +41,7 @@ export const googleService = (clientId: string): OAuthService => { authenticationUrl: encodeURI( `${GOOGLE_BASE}?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid+email+https://www.googleapis.com/auth/userinfo.profile` ), - buttonIcon: , + buttonIcon: , loginButtonTitle: , signUpButtonTitle: , }; @@ -67,7 +66,7 @@ export const oauth2Service = ( return { id: 'oauth2', authenticationUrl: authUrl.toString(), - buttonIcon: , + buttonIcon: , loginButtonTitle: , signUpButtonTitle: , }; diff --git a/webapp/src/component/security/RoleMenu.tsx b/webapp/src/component/security/RoleMenu.tsx index 83889ba500..ecacc921f0 100644 --- a/webapp/src/component/security/RoleMenu.tsx +++ b/webapp/src/component/security/RoleMenu.tsx @@ -7,7 +7,7 @@ import { styled, Tooltip, } from '@mui/material'; -import { ArrowDropDown } from '@mui/icons-material'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; import { OrganizationRoleType } from 'tg.service/response.types'; import { components } from 'tg.service/apiSchema.generated'; diff --git a/webapp/src/component/security/SignUp/SignUpForm.tsx b/webapp/src/component/security/SignUp/SignUpForm.tsx index dc1355f7e5..6c58fa6f9f 100644 --- a/webapp/src/component/security/SignUp/SignUpForm.tsx +++ b/webapp/src/component/security/SignUp/SignUpForm.tsx @@ -9,6 +9,7 @@ import { } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { T, useTranslate } from '@tolgee/react'; +import { Eye, EyeOff } from '@untitled-ui/icons-react'; import { LoadableType, @@ -17,12 +18,11 @@ import { import { TextField } from 'tg.component/common/form/fields/TextField'; import { InvitationCodeService } from 'tg.service/InvitationCodeService'; import { Validation } from 'tg.constants/GlobalValidationSchema'; -import { PasswordLabel } from '../SetPasswordField'; import { useConfig } from 'tg.globalContext/helpers'; +import { PasswordLabel } from '../SetPasswordField'; import { ResourceErrorComponent } from '../../common/form/ResourceErrorComponent'; import { Alert } from '../../common/Alert'; import { SpendingLimitExceededDescription } from '../../billing/SpendingLimitExceeded'; -import { Visibility, VisibilityOff } from '@mui/icons-material'; const StyledInputFields = styled('div')` display: grid; @@ -142,12 +142,9 @@ export const SignUpForm = (props: Props) => { setShowPassword((v) => !v)} + tabIndex={-1} > - {showPassword ? ( - - ) : ( - - )} + {showPassword ? : } ), diff --git a/webapp/src/component/security/UserMenu/OrganizationSwitch.tsx b/webapp/src/component/security/UserMenu/OrganizationSwitch.tsx index f57b654fee..affc8c0c36 100644 --- a/webapp/src/component/security/UserMenu/OrganizationSwitch.tsx +++ b/webapp/src/component/security/UserMenu/OrganizationSwitch.tsx @@ -1,7 +1,7 @@ import { useState, useRef } from 'react'; import { T } from '@tolgee/react'; import { MenuItem, ListItemText } from '@mui/material'; -import { ArrowDropDown } from '@mui/icons-material'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; import { usePreferredOrganization } from 'tg.globalContext/helpers'; import { components } from 'tg.service/apiSchema.generated'; import { OrganizationPopover } from 'tg.component/organizationSwitch/OrganizationPopover'; diff --git a/webapp/src/component/security/UserMenu/ThemeItem.tsx b/webapp/src/component/security/UserMenu/ThemeItem.tsx index 41d5851d9b..817e741c11 100644 --- a/webapp/src/component/security/UserMenu/ThemeItem.tsx +++ b/webapp/src/component/security/UserMenu/ThemeItem.tsx @@ -8,8 +8,8 @@ import { styled, } from '@mui/material'; import { useTranslate } from '@tolgee/react'; +import { Moon01, Sun } from '@untitled-ui/icons-react'; import { useThemeContext } from '../../../ThemeProvider'; -import { DarkMode, LightMode } from '@mui/icons-material'; const StyledButtonGroup = styled(ButtonGroup)` & .${buttonGroupClasses.grouped} { @@ -38,7 +38,7 @@ export const ThemeItem = () => { color={mode === 'light' ? 'primary' : 'default'} onClick={() => setMode('light')} > - + { color={mode === 'dark' ? 'primary' : 'default'} onClick={() => setMode('dark')} > - + + submitForm()} + > + {t('task_detail_submit_button')} + + + + )} + + + )} + + ); +}; diff --git a/webapp/src/component/task/TaskId.tsx b/webapp/src/component/task/TaskId.tsx new file mode 100644 index 0000000000..049017ca57 --- /dev/null +++ b/webapp/src/component/task/TaskId.tsx @@ -0,0 +1,46 @@ +import { Box, styled, SxProps } from '@mui/material'; +import { Link } from 'react-router-dom'; +import { components } from 'tg.service/apiSchema.generated'; +import { getTaskRedirect } from './utils'; + +export const Container = styled(Box)` + color: ${({ theme }) => theme.palette.tokens.icon.secondary}; + font-size: 15px; +`; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +type TaskNumberProps = { + sx?: SxProps; + className?: string; + taskNumber: number; +}; + +export const TaskNumber = ({ sx, className, taskNumber }: TaskNumberProps) => { + return #{taskNumber}; +}; + +type TaskNumberWithLinkProps = { + sx?: SxProps; + className?: string; + taskNumber: number; + project: SimpleProjectModel; +}; + +export const TaskNumberWithLink = ({ + sx, + className, + taskNumber, + project, +}: TaskNumberWithLinkProps) => { + return ( + + #{taskNumber} + + ); +}; diff --git a/webapp/src/component/task/TaskInfoItem.tsx b/webapp/src/component/task/TaskInfoItem.tsx new file mode 100644 index 0000000000..91dcf04094 --- /dev/null +++ b/webapp/src/component/task/TaskInfoItem.tsx @@ -0,0 +1,19 @@ +import { Box, styled } from '@mui/material'; + +const StyledLabel = styled(Box)` + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +type Props = { + label: React.ReactNode; + value: React.ReactNode; +}; + +export const TaskInfoItem = ({ label, value }: Props) => { + return ( + + {label} + {value} + + ); +}; diff --git a/webapp/src/component/task/TaskItem.tsx b/webapp/src/component/task/TaskItem.tsx new file mode 100644 index 0000000000..824e19c869 --- /dev/null +++ b/webapp/src/component/task/TaskItem.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslate } from '@tolgee/react'; +import { Box, IconButton, styled, Tooltip, useTheme } from '@mui/material'; +import { AlarmClock, DotsVertical } from '@untitled-ui/icons-react'; + +import { TaskDetail } from 'tg.component/CustomIcons'; +import { components } from 'tg.service/apiSchema.generated'; +import { BatchProgress } from 'tg.views/projects/translations/BatchOperations/OperationsSummary/BatchProgress'; +import { useDateFormatter } from 'tg.hooks/useLocale'; +import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; +import { Scope } from 'tg.fixtures/permissions'; +import { TaskMenu } from './TaskMenu'; +import { TaskLabel } from './TaskLabel'; +import { getTaskRedirect } from './utils'; +import { TaskState } from './TaskState'; +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { TaskAssignees } from './TaskAssignees'; + +type TaskModel = components['schemas']['TaskModel']; +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const StyledContainer = styled('div')` + display: contents; + &:hover > * { + background: ${({ theme }) => theme.palette.tokens.text._states.hover}; + cursor: pointer; + } +`; + +const StyledItem = styled(Box)` + display: flex; + align-items: center; + align-self: stretch; + justify-self: stretch; + gap: 8px; + color: ${({ theme }) => theme.palette.text.primary}; + text-decoration: none; +`; + +const StyledProgress = styled(StyledItem)` + display: grid; + grid-template-columns: 80px 1fr; + gap: 24px; + color: ${({ theme }) => theme.palette.tokens.icon.secondary}; +`; + +const StyledAssignees = styled(StyledItem)` + justify-content: start; + display: flex; + flex-wrap: wrap; + padding: 8px 0px; +`; + +type Props = { + task: TaskModel; + onDetailOpen: (task: TaskModel) => void; + project: SimpleProjectModel; + projectScopes?: Scope[]; + showProject?: boolean; +}; + +export const TaskItem = ({ + task, + onDetailOpen, + project, + showProject, + projectScopes, +}: Props) => { + const { t } = useTranslate(); + const theme = useTheme(); + const formatDate = useDateFormatter(); + + const [anchorEl, setAnchorEl] = useState(null); + + const handleClose = () => { + setAnchorEl(null); + }; + + const linkProps = { + component: Link, + to: getTaskRedirect(project, task.number), + }; + + return ( + + + + + + {t('task_keys_count', { value: task.totalItems })} + + + {task.state === 'IN_PROGRESS' ? ( + + ) : ( + + )} + {task.dueDate ? ( + + + {formatDate(task.dueDate, { timeZone: 'UTC' })} + + ) : null} + + {showProject && ( + + {project.name}} disableInteractive> +
+ +
+
+
+ )} + + + + + onDetailOpen(task))} + > + + + setAnchorEl(e.currentTarget))} + > + + + + +
+ ); +}; diff --git a/webapp/src/component/task/TaskLabel.tsx b/webapp/src/component/task/TaskLabel.tsx new file mode 100644 index 0000000000..710dcab66c --- /dev/null +++ b/webapp/src/component/task/TaskLabel.tsx @@ -0,0 +1,59 @@ +import { Box, styled, SxProps, Tooltip } from '@mui/material'; +import { FlagImage } from 'tg.component/languages/FlagImage'; +import { components } from 'tg.service/apiSchema.generated'; +import { TaskNumber, TaskNumberWithLink } from './TaskId'; +import { TaskTypeChip } from './TaskTypeChip'; + +type TaskModel = components['schemas']['TaskModel']; +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const StyledContainer = styled(Box)` + display: grid; + grid-auto-flow: column; + align-items: center; + justify-content: start; + gap: 8px; +`; + +const StyledTaskName = styled(Box)` + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +type Props = { + task: TaskModel; + project?: SimpleProjectModel; + sx?: SxProps; + className?: string; + hideType?: boolean; +}; + +export const TaskLabel = ({ + task, + sx, + className, + project, + hideType, +}: Props) => { + return ( + + + + + + + {task.name} + {project ? ( + + ) : ( + + )} + {!hideType && } + + ); +}; diff --git a/webapp/src/component/task/TaskMenu.tsx b/webapp/src/component/task/TaskMenu.tsx new file mode 100644 index 0000000000..3503e9489b --- /dev/null +++ b/webapp/src/component/task/TaskMenu.tsx @@ -0,0 +1,234 @@ +import { useState } from 'react'; +import { Divider, Menu, MenuItem } 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, useApiQuery } from 'tg.service/http/useQueryApi'; + +import { useTaskReport } from './utils'; +import { InitialValues, TaskCreateDialog } from './taskCreate/TaskCreateDialog'; +import { useUser } from 'tg.globalContext/helpers'; + +type TaskModel = components['schemas']['TaskModel']; +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +type Props = { + anchorEl: HTMLElement | null; + onClose: () => void; + task: TaskModel; + project: SimpleProjectModel; + projectScopes?: Scope[]; +}; + +export const TaskMenu = ({ + anchorEl, + onClose, + task, + project, + projectScopes, +}: Props) => { + const user = useUser(); + const isOpen = Boolean(anchorEl); + const [taskCreate, setTaskCreate] = useState>(); + const closeMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/close', + method: 'post', + invalidatePrefix: ['/v2/projects/{projectId}/tasks', '/v2/user-tasks'], + }); + + const reopenMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/reopen', + method: 'post', + invalidatePrefix: ['/v2/projects/{projectId}/tasks', '/v2/user-tasks'], + }); + + const finishMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/finish', + method: 'post', + invalidatePrefix: ['/v2/projects/{projectId}/tasks', '/v2/user-tasks'], + }); + + const { downloadReport } = useTaskReport(); + + const projectLoadable = useApiQuery({ + url: '/v2/projects/{projectId}', + method: 'get', + path: { projectId: project.id }, + options: { + enabled: !projectScopes && isOpen, + refetchOnMount: false, + staleTime: Infinity, + cacheTime: Infinity, + }, + }); + + const scopes = + projectScopes ?? projectLoadable.data?.computedPermission.scopes ?? []; + + const canEditTask = scopes?.includes('tasks.edit'); + const canMarkAsDone = + scopes.includes('tasks.edit') || + Boolean(task.assignees.find((u) => u.id === user?.id)); + + const languagesLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/languages', + method: 'get', + path: { projectId: project.id }, + query: { + page: 0, + size: 1000, + sort: ['tag'], + }, + options: { + enabled: Boolean(taskCreate), + }, + }); + + const taskKeysMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/keys', + method: 'get', + }); + + function handleClose() { + confirmation({ + title: , + onConfirm() { + onClose(); + closeMutation.mutate( + { + path: { projectId: project.id, taskNumber: task.number }, + }, + { + onSuccess() { + messageService.success(); + }, + } + ); + }, + }); + } + + function handleReopen() { + reopenMutation.mutate( + { + path: { projectId: project.id, taskNumber: task.number }, + }, + { + onSuccess() { + onClose(); + messageService.success(); + }, + } + ); + } + + function handleMarkAsDone() { + finishMutation.mutate( + { + path: { projectId: project.id, taskNumber: task.number }, + }, + { + onSuccess() { + onClose(); + messageService.success(); + }, + } + ); + } + + function handleGetExcelReport() { + onClose(); + downloadReport(project.id, task); + } + + function handleCloneTask() { + taskKeysMutation.mutate( + { + path: { projectId: project.id, taskNumber: task.number }, + }, + { + onSuccess(data) { + setTaskCreate({ + selection: data.keys, + name: task.name, + description: task.description, + type: task.type, + }); + onClose(); + }, + } + ); + } + + function handleCreateReviewTask() { + taskKeysMutation.mutate( + { + path: { projectId: project.id, taskNumber: task.number }, + }, + { + onSuccess(data) { + setTaskCreate({ + selection: data.keys, + name: task.name, + description: task.description, + languages: [task.language.id], + type: 'REVIEW', + }); + onClose(); + }, + } + ); + } + + const { t } = useTranslate(); + return ( + <> + + {task.state === 'IN_PROGRESS' || task.state === 'NEW' ? ( + + {t('task_menu_mark_as_done')} + + ) : ( + + {t('task_menu_mark_as_in_progress')} + + )} + {(task.state === 'IN_PROGRESS' || task.state === 'NEW') && ( + + {t('task_menu_close_task')} + + )} + + + {t('task_menu_clone_task')} + + {task.type === 'TRANSLATE' && ( + + {t('task_menu_create_review_task')} + + )} + + + + {t('task_menu_generate_report')} + + + {taskCreate && languagesLoadable.data && ( + setTaskCreate(undefined)} + onFinished={() => setTaskCreate(undefined)} + allLanguages={languagesLoadable.data._embedded?.languages ?? []} + projectId={project.id} + initialValues={taskCreate} + /> + )} + + ); +}; diff --git a/webapp/src/component/task/TaskScope.tsx b/webapp/src/component/task/TaskScope.tsx new file mode 100644 index 0000000000..3d1695337a --- /dev/null +++ b/webapp/src/component/task/TaskScope.tsx @@ -0,0 +1,90 @@ +import { Box, styled } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { useNumberFormatter } from 'tg.hooks/useLocale'; +import { components } from 'tg.service/apiSchema.generated'; +import { BatchProgress } from 'tg.views/projects/translations/BatchOperations/OperationsSummary/BatchProgress'; +import { TaskState } from './TaskState'; +import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; +import React from 'react'; + +type TaskModel = components['schemas']['TaskModel']; +type TaskPerUserReportModel = components['schemas']['TaskPerUserReportModel']; + +const StyledScope = styled(Box)` + display: grid; + background: ${({ theme }) => theme.palette.tokens.background.selected}; + padding: 24px; + border-radius: 8px; + grid-template-columns: 3fr 1fr 1fr 1fr; + gap: 6px; +`; + +type Props = { + task: TaskModel; + perUserData: TaskPerUserReportModel[] | undefined; +}; + +export const TaskScope = ({ task, perUserData }: Props) => { + const formatNumber = useNumberFormatter(); + const { t } = useTranslate(); + + return ( + + + + + + + {Boolean(task.totalItems) && ( + + {formatNumber((task.doneItems / task.totalItems) * 100, { + maximumFractionDigits: 0, + })} + % + + )} + + {t('task_scope_keys_label')} + {t('task_scope_words_label')} + {t('task_scope_characters_label')} + + {t('task_scope_total_to_translate')} + {formatNumber(task.totalItems)} + {formatNumber(task.baseWordCount)} + {formatNumber(task.baseCharacterCount)} + + + + {perUserData?.map((item, i) => ( + + + + {item.user.name} + + {formatNumber(item.doneItems)} + + {formatNumber(item.baseWordCount)} + + + {formatNumber(item.baseCharacterCount)} + + + ))} + + ); +}; diff --git a/webapp/src/component/task/TaskState.tsx b/webapp/src/component/task/TaskState.tsx new file mode 100644 index 0000000000..7ddbe8a9d4 --- /dev/null +++ b/webapp/src/component/task/TaskState.tsx @@ -0,0 +1,35 @@ +import { styled, useTheme } from '@mui/material'; +import { components } from 'tg.service/apiSchema.generated'; +import { useTaskStateTranslation } from 'tg.translationTools/useTaskStateTranslation'; + +type TaskState = components['schemas']['TaskModel']['state']; + +const StyledContainer = styled('span')` + font-weight: 500; +`; + +type Props = { + state: TaskState; +}; + +export const useStateColor = () => { + const theme = useTheme(); + + return (state: TaskState) => + state === 'DONE' + ? theme.palette.tokens._components.progressbar.task.done + : state === 'IN_PROGRESS' + ? theme.palette.tokens._components.progressbar.task.inProgress + : theme.palette.tokens.text.secondary; +}; + +export const TaskState = ({ state }: Props) => { + const translateState = useTaskStateTranslation(); + const stateColor = useStateColor(); + + return ( + + {translateState(state)} + + ); +}; diff --git a/webapp/src/component/task/TaskTooltip.tsx b/webapp/src/component/task/TaskTooltip.tsx new file mode 100644 index 0000000000..a19c83f0ae --- /dev/null +++ b/webapp/src/component/task/TaskTooltip.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Dialog, IconButton, Tooltip } from '@mui/material'; +import { Translate01 } from '@untitled-ui/icons-react'; +import { TaskDetail as TaskDetailIcon } from 'tg.component/CustomIcons'; +import { useTranslate } from '@tolgee/react'; + +import { TaskTooltipContent } from './TaskTooltipContent'; +import { components } from 'tg.service/apiSchema.generated'; +import { TaskDetail } from './TaskDetail'; +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { getTaskRedirect } from './utils'; + +type TaskModel = components['schemas']['TaskModel']; +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +type Action = 'open' | 'detail'; + +type Props = { + taskNumber: number; + project: SimpleProjectModel; + children: React.ReactElement; + actions?: Action[] | React.ReactNode | ((task: TaskModel) => React.ReactNode); +} & Omit, 'title'>; + +export const TaskTooltip = ({ + taskNumber, + project, + children, + actions = ['open', 'detail'], + ...tooltipProps +}: Props) => { + const [taskDetailData, setTaskDetailData] = useState(); + const { t } = useTranslate(); + + const actionsContent = Array.isArray(actions) + ? (task: TaskModel) => ( + <> + {actions.includes('open') && ( + + + + + + )} + {actions.includes('detail') && ( + + setTaskDetailData(task)}> + + + + )} + + ) + : null; + + return ( + <> + + } + > + {children} + + {taskDetailData && ( + setTaskDetailData(undefined)} + maxWidth="xl" + onClick={stopAndPrevent()} + > + setTaskDetailData(undefined)} + projectId={project.id} + /> + + )} + + ); +}; diff --git a/webapp/src/component/task/TaskTooltipContent.tsx b/webapp/src/component/task/TaskTooltipContent.tsx new file mode 100644 index 0000000000..486aba9e50 --- /dev/null +++ b/webapp/src/component/task/TaskTooltipContent.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { Box, styled } from '@mui/material'; +import { T } from '@tolgee/react'; + +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { LoadingSkeleton } from 'tg.component/LoadingSkeleton'; +import { useLoadingRegister } from 'tg.component/GlobalLoading'; +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { components } from 'tg.service/apiSchema.generated'; +import { TaskLabel } from './TaskLabel'; +import { TaskState } from './TaskState'; +import { AlarmClock } from '@untitled-ui/icons-react'; +import { useDateFormatter } from 'tg.hooks/useLocale'; +import { BatchProgress } from 'tg.views/projects/translations/BatchOperations/OperationsSummary/BatchProgress'; + +type TaskModel = components['schemas']['TaskModel']; + +const StyledProgress = styled(Box)` + display: flex; + justify-content: space-between + align-items: center; + gap: 24px; +`; + +type Props = { + taskNumber: number; + projectId: number; + actions?: React.ReactNode | ((task: TaskModel) => React.ReactNode); +}; + +export const TaskTooltipContent = ({ + projectId, + taskNumber, + actions, +}: Props) => { + const task = useApiQuery({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}', + method: 'get', + path: { + projectId, + taskNumber, + }, + fetchOptions: { + disableAuthRedirect: true, + }, + }); + + const formatDate = useDateFormatter(); + + useLoadingRegister(task.isFetching); + + const assignees = task.data?.assignees ?? []; + + return ( + + {task.isLoading && ( + <> + + + + + )} + {task.error?.code === 'operation_not_permitted' && ( + + + + )} + {task.data && ( + + + + {actions && ( + + {typeof actions === 'function' ? actions(task.data) : actions} + + )} + + + {assignees.length ? ( + <> + {' '} + {(assignees[0].name ?? '') + + (assignees.length > 1 ? ` (+${assignees.length - 1})` : '')} + + ) : ( + + )} + + + + + {(task.data.state === 'IN_PROGRESS' || + task.data.state === 'NEW') && ( + + + + )} + + {task.data.dueDate ? ( + + + {formatDate(task.data.dueDate, { timeZone: 'UTC' })} + + ) : null} + + + )} + + ); +}; diff --git a/webapp/src/component/task/TaskTypeChip.tsx b/webapp/src/component/task/TaskTypeChip.tsx new file mode 100644 index 0000000000..bc5f8042cc --- /dev/null +++ b/webapp/src/component/task/TaskTypeChip.tsx @@ -0,0 +1,33 @@ +import { Chip, styled, Theme, useTheme } from '@mui/material'; +import { components } from 'tg.service/apiSchema.generated'; +import { useTaskTypeTranslation } from 'tg.translationTools/useTaskTranslation'; + +type TaskType = components['schemas']['TaskModel']['type']; + +const StyledChip = styled(Chip)``; + +export function getBackgroundColor(type: TaskType, theme: Theme) { + switch (type) { + case 'TRANSLATE': + return theme.palette.tokens.text._states.focus; + case 'REVIEW': + return theme.palette.tokens.secondary._states.focus; + } +} + +type Props = { + type: TaskType; +}; + +export const TaskTypeChip = ({ type }: Props) => { + const translateTaskType = useTaskTypeTranslation(); + const theme = useTheme(); + + return ( + + ); +}; diff --git a/webapp/src/component/task/TasksBoard.tsx b/webapp/src/component/task/TasksBoard.tsx new file mode 100644 index 0000000000..72fb79e76a --- /dev/null +++ b/webapp/src/component/task/TasksBoard.tsx @@ -0,0 +1,143 @@ +import { Box, styled, useTheme } from '@mui/material'; +import { components } from 'tg.service/apiSchema.generated'; +import { BoxLoading } from 'tg.component/common/BoxLoading'; +import { BoardColumn } from 'tg.component/task/BoardColumn'; +import { useTranslate } from '@tolgee/react'; +import { LoadingButton } from '@mui/lab'; + +import { useTaskStateTranslation } from 'tg.translationTools/useTaskStateTranslation'; +import { useStateColor } from 'tg.component/task/TaskState'; +import type { useProjectBoardTasks } from 'tg.views/projects/tasks/useProjectBoardTasks'; + +type TaskModel = components['schemas']['TaskModel']; +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const StyledColumns = styled(Box)` + padding-top: 12px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; + min-width: 800px; +`; + +const StyledContainer = styled(Box)` + display: grid; + overflow: auto; +`; + +type TasksLoadable = ReturnType; + +type Props = { + showClosed: boolean; + onOpenDetail: (task: TaskModel) => void; + newTasks: TasksLoadable; + inProgressTasks: TasksLoadable; + doneTasks: TasksLoadable; + project?: SimpleProjectModel; +}; + +export const TasksBoard = ({ + showClosed, + onOpenDetail, + newTasks, + inProgressTasks, + doneTasks, + project, +}: Props) => { + const theme = useTheme(); + const { t } = useTranslate(); + const translateState = useTaskStateTranslation(); + const stateColor = useStateColor(); + + const canFetchMore = + newTasks.hasNextPage || + inProgressTasks.hasNextPage || + doneTasks.hasNextPage; + + function handleFetchMore() { + newTasks.fetchNextPage(); + inProgressTasks.fetchNextPage(); + doneTasks.fetchNextPage(); + } + + const isLoading = + newTasks.isLoading || inProgressTasks.isLoading || doneTasks.isLoading; + const isFetching = + newTasks.isFetching || inProgressTasks.isFetching || doneTasks.isFetching; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + + + + + {translateState('DONE')} + + + {' & '} + {translateState('CLOSED')} + +
+ ) : ( + + + {translateState('DONE')} + + + {' '} + {t('task_board_last_30_days')} + + + ) + } + tasks={doneTasks.items} + total={doneTasks.data?.pages?.[0]?.page?.totalElements ?? 0} + project={project} + onDetailOpen={onOpenDetail} + /> + {canFetchMore && ( + + + {t('global_load_more')} + + + )} + + + ); +}; diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx new file mode 100644 index 0000000000..bd6567f49e --- /dev/null +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx @@ -0,0 +1,120 @@ +import { useRef, useState } from 'react'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; +import { XClose } from '@untitled-ui/icons-react'; +import { Box, styled, IconButton, SxProps } from '@mui/material'; + +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { TextField } from 'tg.component/common/TextField'; +import { + AssigneeFilters, + AssigneeSearchSelectPopover, +} from './AssigneeSearchSelectPopover'; +import { FakeInput } from 'tg.component/FakeInput'; +import { User } from 'tg.component/UserAccount'; + +const StyledClearButton = styled(IconButton)` + margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +type Props = { + value: User[]; + onChange?: (users: User[]) => void; + label: React.ReactNode; + sx?: SxProps; + className?: string; + projectId: number; + disabled?: boolean; + filters?: AssigneeFilters; +}; + +export const AssigneeSearchSelect: React.FC = ({ + value, + onChange, + label, + sx, + className, + projectId, + disabled, + filters, +}) => { + const anchorEl = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + }; + + const handleClick = () => { + if (!disabled) { + setIsOpen(true); + } + }; + + const handleSelectOrganization = async (users: User[]) => { + onChange?.(users); + setIsOpen(false); + }; + + const handleClearAssignees = () => { + onChange?.([]); + }; + + return ( + <> + + u.name).join(', ')} + data-cy="assignee-select" + minHeight={false} + label={label} + disabled={disabled} + InputProps={{ + onClick: handleClick, + disabled: disabled, + ref: anchorEl, + fullWidth: true, + sx: { + cursor: 'pointer', + }, + readOnly: true, + inputComponent: FakeInput, + margin: 'dense', + endAdornment: ( + + {Boolean(value.length && !disabled) && ( + + + + )} + + + + + ), + }} + /> + + + + + ); +}; diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx new file mode 100644 index 0000000000..fd7c47d641 --- /dev/null +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useState } from 'react'; +import { + MenuItem, + Popover, + Autocomplete, + InputBase, + Box, + styled, + Button, + Checkbox, + PopoverOrigin, +} from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { useDebounce } from 'use-debounce'; + +import { operations } from 'tg.service/apiSchema.generated'; +import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { User, UserAccount } from 'tg.component/UserAccount'; + +export type AssigneeFilters = + operations['getPossibleAssignees']['parameters']['query']; + +const USERS_SEARCH_TRESHOLD = 5; + +const StyledInput = styled(InputBase)` + padding: 5px 4px 3px 16px; + flex-grow: 1; +`; + +const StyledInputWrapper = styled(Box)` + display: flex; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider1}; + padding-right: 4px; +`; + +const StyledWrapper = styled('div')` + display: grid; +`; + +const StyledProgressContainer = styled('div')` + display: flex; + align-items: center; + margin-left: -18px; +`; + +function PopperComponent(props) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { disablePortal, anchorEl, open, ...other } = props; + return ; +} + +function PaperComponent(props) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { disablePortal, anchorEl, open, ...other } = props; + return ; +} + +type Props = { + open: boolean; + onClose: () => void; + onSelect?: (value: User[]) => void; + onSelectImmediate?: (value: User[]) => void; + anchorEl: HTMLElement; + selected: User[]; + ownedOnly?: boolean; + projectId: number; + anchorOrigin?: PopoverOrigin; + transformOrigin?: PopoverOrigin; + filters?: AssigneeFilters; +}; + +export const AssigneeSearchSelectPopover: React.FC = ({ + open, + onClose, + onSelect, + onSelectImmediate, + anchorEl, + selected, + projectId, + anchorOrigin, + transformOrigin, + filters, +}) => { + const [inputValue, setInputValue] = useState(''); + const { t } = useTranslate(); + const [search] = useDebounce(inputValue, 500); + const [selection, setSelection] = useState(selected); + + useEffect(() => { + setInputValue(''); + setSelection(selected); + }, [open]); + + const query = { + search, + size: 20, + sort: ['name'], + ...filters, + }; + + const usersLoadable = useApiInfiniteQuery({ + url: '/v2/projects/{projectId}/tasks/possible-assignees', + method: 'get', + path: { projectId }, + query, + options: { + enabled: open, + keepPreviousData: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + path: { projectId }, + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + const items: User[] = usersLoadable.data?.pages + .flatMap((page) => page._embedded?.users) + .filter(Boolean) as User[]; + + const [displaySearch, setDisplaySearch] = useState( + undefined + ); + + useEffect(() => { + if (usersLoadable.data && displaySearch === undefined) { + setDisplaySearch( + usersLoadable.data.pages[0].page!.totalElements! > USERS_SEARCH_TRESHOLD + ); + } + }, [usersLoadable.data]); + + if (!selected) { + return null; + } + + return ( + <> + { + onSelect?.(selection); + onClose(); + }} + anchorOrigin={ + anchorOrigin ?? { + vertical: 'top', + horizontal: 'center', + } + } + transformOrigin={ + transformOrigin ?? { + vertical: 'top', + horizontal: 'center', + } + } + > + + x} + loading={usersLoadable.isFetching} + options={items || []} + value={selection} + inputValue={inputValue} + onClose={(_, reason) => reason === 'escape' && onClose()} + clearOnEscape={false} + noOptionsText={t('global_nothing_found')} + loadingText={t('global_loading_text')} + isOptionEqualToValue={(o, v) => o.id === v.id} + onInputChange={(_, value, reason) => + reason === 'input' && setInputValue(value) + } + getOptionLabel={(u) => u.name || ''} + PopperComponent={PopperComponent} + PaperComponent={PaperComponent} + renderOption={(props, option) => { + const selected = Boolean( + selection.find((u) => u.id === option.id) + ); + return ( + + + + + + {usersLoadable.hasNextPage && + option.id === items![items!.length - 1].id && ( + + + + )} + + ); + }} + onChange={(_, newValue) => { + onSelectImmediate?.(newValue); + setSelection(newValue); + }} + renderInput={(params) => ( + + + + + ) : undefined + } + /> + + )} + /> + + + + ); +}; diff --git a/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx b/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx new file mode 100644 index 0000000000..be4838ff39 --- /dev/null +++ b/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx @@ -0,0 +1,325 @@ +import { + Box, + Button, + Checkbox, + Dialog, + DialogTitle, + ListItemText, + MenuItem, + styled, + Typography, +} from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { Formik } from 'formik'; +import { useState } from 'react'; + +import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; +import { messageService } from 'tg.service/MessageService'; +import { useTaskTypeTranslation } from 'tg.translationTools/useTaskTranslation'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { Select as FormSelect } from 'tg.component/common/form/fields/Select'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { FiltersType } from 'tg.component/translation/translationFilters/tools'; +import { TranslationFilters } from 'tg.component/translation/translationFilters/TranslationFilters'; +import { Select } from 'tg.component/common/Select'; +import { User } from 'tg.component/UserAccount'; + +import { TaskDatePicker } from '../TaskDatePicker'; +import { TaskPreview } from './TaskPreview'; +import { + TranslationStateFilter, + TranslationStateType, +} from './TranslationStateFilter'; + +type TaskType = components['schemas']['TaskModel']['type']; +type LanguageModel = components['schemas']['LanguageModel']; + +const TASK_TYPES: TaskType[] = ['TRANSLATE', 'REVIEW']; + +const StyledMainTitle = styled(DialogTitle)` + padding-bottom: 0px; +`; + +const StyledSubtitle = styled('div')` + padding: ${({ theme }) => theme.spacing(0, 3, 2, 3)}; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const StyledForm = styled('form')` + display: grid; + padding: ${({ theme }) => theme.spacing(3)}; + gap: ${({ theme }) => theme.spacing(0.5, 3)}; + padding-top: ${({ theme }) => theme.spacing(1)}; + width: min(calc(100vw - 64px), 800px); +`; + +const StyledTopPart = styled(Box)` + display: grid; + gap: ${({ theme }) => theme.spacing(0.5, 2)}; + grid-template-columns: 3fr 5fr; + align-items: start; + ${({ theme }) => theme.breakpoints.down('sm')} { + grid-template-columns: 1fr; + } +`; + +const StyledFilters = styled(Box)` + display: grid; + gap: ${({ theme }) => theme.spacing(0.5, 2)}; + grid-template-columns: 3fr 3fr 2fr; + ${({ theme }) => theme.breakpoints.down('sm')} { + grid-template-columns: 1fr; + gap: ${({ theme }) => theme.spacing(2)}; + } +`; + +const StyledActions = styled('div')` + display: flex; + gap: 8px; + padding-top: 24px; + justify-content: end; +`; + +export type InitialValues = { + type: TaskType; + name: string; + description: string; + languages: number[]; + languageAssignees: Record; + selection: number[]; +}; + +type Props = { + open: boolean; + onClose: () => void; + onFinished: () => void; + projectId: number; + allLanguages: LanguageModel[]; + initialValues?: Partial; +}; + +export const TaskCreateDialog = ({ + open, + onClose, + onFinished, + projectId, + allLanguages, + initialValues, +}: Props) => { + const { t } = useTranslate(); + + const translateTaskType = useTaskTypeTranslation(); + + const createTasksLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/create-multiple', + method: 'post', + invalidatePrefix: ['/v2/projects/{projectId}/tasks', '/v2/user-tasks'], + }); + + const [filters, setFilters] = useState({}); + const [stateFilters, setStateFilters] = useState([]); + const [languages, setLanguages] = useState(initialValues?.languages ?? []); + + const selectedLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/translations/select-all', + method: 'get', + path: { projectId }, + query: { + ...filters, + languages: allLanguages + .filter((l) => languages.includes(l.id)) + .map((l) => l.tag), + }, + options: { + enabled: !initialValues?.selection, + }, + }); + + const selectedKeys = + initialValues?.selection ?? selectedLoadable.data?.ids ?? []; + + return ( + + + + + + + + + { + const data = languages.map((languageId) => ({ + type: values.type, + name: values.name, + description: values.description, + languageId: languageId, + dueDate: values.dueDate, + assignees: values.assignees[languageId]?.map((u) => u.id) ?? [], + keys: selectedKeys, + })); + createTasksLoadable.mutate( + { + path: { projectId }, + query: { + filterState: stateFilters.filter((i) => i !== 'OUTDATED'), + filterOutdated: stateFilters.includes('OUTDATED'), + }, + content: { + 'application/json': { tasks: data }, + }, + }, + { + onSuccess() { + messageService.success( + + ); + onFinished(); + }, + } + ); + }} + > + {({ values, handleSubmit, setFieldValue, submitForm }) => { + return ( + + + translateTaskType(v)} + fullWidth + > + {TASK_TYPES.map((v) => ( + + {translateTaskType(v)} + + ))} + + + + + setFieldValue('dueDate', value)} + label={t('create_task_field_due_date')} + /> + + + + + {t('create_task_tasks_and_assignees_title')} + + + {!initialValues?.selection && ( + + languages.includes(l.id) + )} + placeholder={t('create_task_filter_keys_placeholder')} + filterOptions={{ keyRelatedOnly: true }} + sx={{ width: '100%', maxWidth: '270px' }} + /> + )} + + + + {allLanguages && ( + + {languages?.map((language) => ( + l.id === language)!} + type={values.type} + keys={selectedKeys} + assigness={values.assignees[language] ?? []} + onUpdateAssignees={(users) => { + setFieldValue(`assignees[${language}]`, users); + }} + filters={stateFilters} + projectId={projectId} + /> + ))} + + )} + + + + + {t('create_task_submit_button')} + + + + ); + }} + + + ); +}; diff --git a/webapp/src/component/task/taskCreate/TaskPreview.tsx b/webapp/src/component/task/taskCreate/TaskPreview.tsx new file mode 100644 index 0000000000..a95928a71a --- /dev/null +++ b/webapp/src/component/task/taskCreate/TaskPreview.tsx @@ -0,0 +1,156 @@ +import { AlertTriangle } from '@untitled-ui/icons-react'; +import { Box, Skeleton, styled, Tooltip, useTheme } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; + +import { components } from 'tg.service/apiSchema.generated'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { stringHash } from 'tg.fixtures/stringHash'; +import { FlagImage } from 'tg.component/languages/FlagImage'; +import { useNumberFormatter } from 'tg.hooks/useLocale'; +import { User } from 'tg.component/UserAccount'; +import { AssigneeSearchSelect } from '../assigneeSelect/AssigneeSearchSelect'; +import { TranslationStateType } from './TranslationStateFilter'; + +type TaskType = components['schemas']['TaskModel']['type']; +type LanguageModel = components['schemas']['LanguageModel']; + +const StyledContainer = styled('div')` + display: grid; + padding: 16px 20px; + grid-template-columns: 1fr 3fr 2fr; + border-radius: 8px; + background: ${({ theme }) => theme.palette.tokens.background.selected}; + ${({ theme }) => theme.breakpoints.down('sm')} { + grid-template-columns: 1fr; + } +`; + +const StyledContent = styled('div')` + display: flex; + align-items: center; + padding: 8px 12px; + flex-shrink: 0; + gap: 8px; +`; + +const StyledMetric = styled('div')` + font-size: 15px; + color: ${({ theme }) => theme.palette.tokens.text.secondary}; +`; + +const StyledSmallCaption = styled('div')` + font-size: 12px; + position: relative; + margin-bottom: -3px; +`; + +type Props = { + type: TaskType; + language: LanguageModel; + keys: number[]; + assigness: User[]; + onUpdateAssignees: (users: User[]) => void; + filters: TranslationStateType[]; + projectId: number; +}; + +export const TaskPreview = ({ + type, + language, + keys, + assigness, + onUpdateAssignees, + filters, + projectId, +}: Props) => { + const { t } = useTranslate(); + const formatNumber = useNumberFormatter(); + const theme = useTheme(); + + const content = { keys, type, language: language.id }; + const statsLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/tasks/calculate-scope', + method: 'post', + path: { projectId }, + content: { 'application/json': content }, + query: { + // @ts-ignore add dependencies to url, so react query works correctly + hash: stringHash(JSON.stringify(content)), + filterState: filters.filter((i) => i !== 'OUTDATED'), + filterOutdated: filters.includes('OUTDATED'), + }, + }); + + return ( + + + + + {language.name} + + + + + + {t('create_task_preview_keys')} + + {statsLoadable.data ? ( + + {formatNumber(statsLoadable.data.keyCount)} + {statsLoadable.data.keyCount !== keys.length && ( + + + + + + )} + + ) : ( + + )} + + + + {t('create_task_preview_words')} + + {statsLoadable.data ? ( + formatNumber(statsLoadable.data.wordCount) + ) : ( + + )} + + + + {t('create_task_preview_characters')} + + {statsLoadable.data ? ( + formatNumber(statsLoadable.data.characterCount) + ) : ( + + )} + + + + + {t('create_task_preview_assignee')} + + } + filters={{ + filterMinimalScope: 'TRANSLATIONS_VIEW', + filterViewLanguageId: language.id, + }} + /> + + ); +}; diff --git a/webapp/src/component/task/taskCreate/TranslationStateFilter.tsx b/webapp/src/component/task/taskCreate/TranslationStateFilter.tsx new file mode 100644 index 0000000000..af9a2be800 --- /dev/null +++ b/webapp/src/component/task/taskCreate/TranslationStateFilter.tsx @@ -0,0 +1,134 @@ +import { XClose } from '@untitled-ui/icons-react'; +import { + Checkbox, + ListItemText, + MenuItem, + styled, + SxProps, + Select, + Box, + IconButton, +} from '@mui/material'; +import { StateType, TRANSLATION_STATES } from 'tg.constants/translationStates'; +import { stopBubble } from 'tg.fixtures/eventHandler'; +import { useStateTranslation } from 'tg.translationTools/useStateTranslation'; + +export type TranslationStateType = StateType | 'OUTDATED'; + +const StyledDot = styled('div')` + width: 8px; + height: 8px; + border-radius: 4px; + margin-left: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledInputButton = styled(IconButton)` + margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +const StyledPlaceholder = styled(Box)` + opacity: 0.5; +`; + +type Props = { + value: TranslationStateType[]; + onChange: (value: TranslationStateType[]) => void; + placeholder?: string; + sx?: SxProps; + className?: string; +}; + +export const TranslationStateFilter = ({ + value, + onChange, + placeholder, + sx, + className, +}: Props) => { + const translateState = useStateTranslation(); + + const handleToggle = (item: TranslationStateType) => () => { + if (value.includes(item)) { + onChange(value.filter((i) => i !== item)); + } else { + onChange([...value, item]); + } + }; + + return ( + + ); +}; diff --git a/webapp/src/component/task/taskFilter/SubfilterAssignees.tsx b/webapp/src/component/task/taskFilter/SubfilterAssignees.tsx new file mode 100644 index 0000000000..a1dc547039 --- /dev/null +++ b/webapp/src/component/task/taskFilter/SubfilterAssignees.tsx @@ -0,0 +1,46 @@ +import { useRef, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; + +import { SubmenuItem } from './SubmenuItem'; +import { AssigneeSearchSelectPopover } from '../assigneeSelect/AssigneeSearchSelectPopover'; + +type Props = { + value: number[]; + onChange: (value: number[]) => void; + projectId: number; +}; + +export const SubfilterAssignees = ({ value, onChange, projectId }: Props) => { + const { t } = useTranslate(); + const [open, setOpen] = useState(false); + const anchorEl = useRef(null); + + return ( + <> + setOpen(true)} + selected={Boolean(value?.length)} + /> + {open && ( + setOpen(false)} + anchorEl={anchorEl.current!} + selected={value.map((id) => ({ id, name: '', username: '' }))} + onSelectImmediate={(users) => onChange(users.map((u) => u.id))} + projectId={projectId} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + /> + )} + + ); +}; diff --git a/webapp/src/component/task/taskFilter/SubfilterLanguages.tsx b/webapp/src/component/task/taskFilter/SubfilterLanguages.tsx new file mode 100644 index 0000000000..8f84741ff1 --- /dev/null +++ b/webapp/src/component/task/taskFilter/SubfilterLanguages.tsx @@ -0,0 +1,71 @@ +import { useRef, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { Checkbox, ListItemText, Menu, MenuItem } from '@mui/material'; + +import { components } from 'tg.service/apiSchema.generated'; +import { SubmenuItem } from './SubmenuItem'; + +type LanguageModel = components['schemas']['LanguageModel']; + +type Props = { + value: number[]; + onChange: (value: number[]) => void; + languages: LanguageModel[]; +}; + +export const SubfilterLanguages = ({ value, onChange, languages }: Props) => { + const { t } = useTranslate(); + const [open, setOpen] = useState(false); + const anchorEl = useRef(null); + const handleLanguageToggle = (id: number) => () => { + if (value.includes(id)) { + onChange(value.filter((l) => l !== id)); + } else { + onChange([...value, id]); + } + }; + + return ( + <> + setOpen(true)} + selected={Boolean(value?.length)} + /> + {open && ( + { + setOpen(false); + }} + > + {languages.map((lang) => ( + + + + + ))} + + )} + + ); +}; diff --git a/webapp/src/component/task/taskFilter/SubfilterProjects.tsx b/webapp/src/component/task/taskFilter/SubfilterProjects.tsx new file mode 100644 index 0000000000..9dd4a530ab --- /dev/null +++ b/webapp/src/component/task/taskFilter/SubfilterProjects.tsx @@ -0,0 +1,44 @@ +import { useRef, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; + +import { SubmenuItem } from './SubmenuItem'; +import { ProjectSearchSelectPopover } from 'tg.component/projectSearchSelect/ProjectSearchSelectPopover'; + +type Props = { + value: number[]; + onChange: (value: number[]) => void; +}; + +export const SubfilterProjects = ({ value, onChange }: Props) => { + const { t } = useTranslate(); + const [open, setOpen] = useState(false); + const anchorEl = useRef(null); + + return ( + <> + setOpen(true)} + selected={Boolean(value?.length)} + /> + {open && ( + setOpen(false)} + anchorEl={anchorEl.current!} + selected={value.map((id) => ({ id, name: '', username: '' }))} + onSelectImmediate={(users) => onChange(users.map((u) => u.id))} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + /> + )} + + ); +}; diff --git a/webapp/src/component/task/taskFilter/SubmenuItem.tsx b/webapp/src/component/task/taskFilter/SubmenuItem.tsx new file mode 100644 index 0000000000..70fa31bcc2 --- /dev/null +++ b/webapp/src/component/task/taskFilter/SubmenuItem.tsx @@ -0,0 +1,24 @@ +import { ArrowRight } from 'tg.component/CustomIcons'; +import { ListItemText, MenuItem, MenuItemProps, styled } from '@mui/material'; +import React from 'react'; + +const StyledMenuItem = styled(MenuItem)` + display: flex; + justify-content: space-between; +`; + +type Props = MenuItemProps & { + label: React.ReactNode; +}; + +export const SubmenuItem = React.forwardRef(function SubmenuItem( + { label, ...other }: Props, + ref +) { + return ( + + + + + ); +}); diff --git a/webapp/src/component/task/taskFilter/TaskFilter.tsx b/webapp/src/component/task/taskFilter/TaskFilter.tsx new file mode 100644 index 0000000000..322a3d70ad --- /dev/null +++ b/webapp/src/component/task/taskFilter/TaskFilter.tsx @@ -0,0 +1,215 @@ +import { ArrowDropDown } from 'tg.component/CustomIcons'; +import { XClose } from '@untitled-ui/icons-react'; +import { Box, IconButton, styled, SxProps, Tooltip } from '@mui/material'; +import { useRef, useState } from 'react'; +import { TextField } from 'tg.component/common/TextField'; +import { FakeInput } from 'tg.component/FakeInput'; +import { TaskFilterPopover, TaskFilterType } from './TaskFilterPopover'; +import { components } from 'tg.service/apiSchema.generated'; +import { useTranslate } from '@tolgee/react'; +import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; +import { FlagImage } from 'tg.component/languages/FlagImage'; +import { TaskTypeChip } from '../TaskTypeChip'; +import { filterEmpty } from './taskFilterUtils'; +import { stopBubble } from 'tg.fixtures/eventHandler'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const StyledInputButton = styled(IconButton)` + margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +const StyledContent = styled(Box)` + display: flex; + height: 100%; + gap: 6px; + align-items: center; + & > * { + flex-shrink: 0; + } +`; + +type Props = { + value: TaskFilterType; + onChange: (value: TaskFilterType) => void; + project?: SimpleProjectModel; + sx?: SxProps; + className?: string; +}; + +const FilterTooltip = ({ + title, + children, +}: { + title: string | undefined; + children: React.ReactNode; +}) => { + return ( + + + {children} + + + ); +}; + +export const TaskFilter = ({ + value, + onChange, + project, + sx, + className, +}: Props) => { + const anchorEl = useRef(null); + const [open, setOpen] = useState(false); + const { t } = useTranslate(); + + const usersLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/tasks/possible-assignees', + method: 'get', + path: { projectId: project?.id ?? 0 }, + query: { size: 10000, filterId: value.assignees }, + options: { + enabled: Boolean(value.assignees?.length) && Boolean(project), + keepPreviousData: true, + }, + }); + + const languagesLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/languages', + method: 'get', + path: { projectId: project?.id ?? 0 }, + query: { size: 10000 }, + options: { + enabled: Boolean(project), + keepPreviousData: true, + }, + }); + + const projectsLoadable = useApiQuery({ + url: '/v2/projects', + method: 'get', + query: { size: 10000, filterId: value.projects }, + options: { + enabled: !project && Boolean(value.projects?.length), + keepPreviousData: true, + }, + }); + + const languages = languagesLoadable.data?._embedded?.languages ?? []; + + function getFilterValue(value: TaskFilterType) { + if (filterEmpty(value)) { + return null; + } + + return ( + + {usersLoadable.data?._embedded?.users + ?.filter((u) => value.assignees?.includes(u.id)) + ?.map((user) => ( + + + + ))} + {languages + ?.filter((l) => value.languages?.includes(l.id)) + .map((language) => ( + + + + ))} + {projectsLoadable.data?._embedded?.projects + ?.filter((p) => value.projects?.includes(p.id)) + .map((project) => ( + + + + ))} + {value.types?.map((type) => ( + + ))} + + ); + } + + function handleClick() { + setOpen(true); + } + + return ( + <> + + {!filterEmpty(value) && ( + onChange({}))} + tabIndex={-1} + > + + + )} + + + + + ), + }} + {...{ sx, className }} + /> + {open && ( + setOpen(false)} + value={value} + onChange={onChange} + anchorEl={anchorEl.current!} + project={project} + languages={languages} + /> + )} + + ); +}; diff --git a/webapp/src/component/task/taskFilter/TaskFilterPopover.tsx b/webapp/src/component/task/taskFilter/TaskFilterPopover.tsx new file mode 100644 index 0000000000..98870c261f --- /dev/null +++ b/webapp/src/component/task/taskFilter/TaskFilterPopover.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { + Checkbox, + ListItemText, + ListSubheader, + Menu, + MenuItem, + styled, +} from '@mui/material'; +import { useDebouncedCallback } from 'use-debounce'; + +import { components } from 'tg.service/apiSchema.generated'; +import { SubfilterAssignees } from './SubfilterAssignees'; +import { SubfilterLanguages } from './SubfilterLanguages'; +import { SubfilterProjects } from './SubfilterProjects'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; +type TaskType = components['schemas']['TaskModel']['type']; +type LanguageModel = components['schemas']['LanguageModel']; + +const StyledListSubheader = styled(ListSubheader)` + line-height: unset; + padding-top: ${({ theme }) => theme.spacing(2)}; + padding-bottom: ${({ theme }) => theme.spacing(0.5)}; +`; + +export type TaskFilterType = { + languages?: number[]; + assignees?: number[]; + projects?: number[]; + types?: TaskType[]; + doneMinClosedAt?: number; +}; + +type Props = { + value: TaskFilterType; + onChange: (value: TaskFilterType) => void; + onClose: () => void; + open: boolean; + anchorEl: HTMLElement; + project?: SimpleProjectModel; + languages: LanguageModel[]; +}; + +export const TaskFilterPopover: React.FC = ({ + value: initialValue, + onChange, + onClose, + open, + anchorEl, + project, + languages, +}) => { + const [value, setValue] = useState(initialValue); + const debouncedOnChange = useDebouncedCallback(onChange, 200); + + function handleChange(value: TaskFilterType) { + setValue(value); + debouncedOnChange(value); + } + + const { t } = useTranslate(); + + const toggleType = (type: TaskType) => () => { + if (value.types?.includes(type)) { + handleChange({ + ...value, + types: [], + }); + } else { + handleChange({ + ...value, + types: [type], + }); + } + }; + + return ( + + {project && ( + handleChange({ ...value, assignees })} + projectId={project.id} + /> + )} + + {project && ( + handleChange({ ...value, languages })} + languages={languages ?? []} + /> + )} + + {!project && ( + handleChange({ ...value, projects })} + /> + )} + + + {t('task_filter_type_label')} + + + + + + + + + + + ); +}; diff --git a/webapp/src/component/task/taskFilter/taskFilterUtils.ts b/webapp/src/component/task/taskFilter/taskFilterUtils.ts new file mode 100644 index 0000000000..b2d0b3daf4 --- /dev/null +++ b/webapp/src/component/task/taskFilter/taskFilterUtils.ts @@ -0,0 +1,10 @@ +import { TaskFilterType } from './TaskFilterPopover'; + +export const filterEmpty = (filter: TaskFilterType) => { + return ( + !filter.assignees?.length && + !filter.languages?.length && + !filter.projects?.length && + !filter.types?.length + ); +}; diff --git a/webapp/src/component/task/taskSelect/TaskSearchSelect.tsx b/webapp/src/component/task/taskSelect/TaskSearchSelect.tsx new file mode 100644 index 0000000000..e02cc679ac --- /dev/null +++ b/webapp/src/component/task/taskSelect/TaskSearchSelect.tsx @@ -0,0 +1,131 @@ +import { useRef, useState } from 'react'; +import { + Box, + styled, + IconButton, + InputBaseComponentProps, + SxProps, +} from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; + +import { components } from 'tg.service/apiSchema.generated'; +import { TextField } from 'tg.component/common/TextField'; +import { TaskSearchSelectPopover } from './TaskSearchSelectPopover'; +import { Task } from './types'; +import React from 'react'; +import { TaskLabel } from '../TaskLabel'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const StyledClearButton = styled(IconButton)` + margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +const StyledFakeInput = styled('div')` + padding: 8.5px 14px; + height: 23px; + box-sizing: content-box; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +`; + +const StyledPlaceholder = styled('span')` + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const FakeInput = React.forwardRef(function FakeInput( + { value, ...rest }: InputBaseComponentProps, + ref +) { + const { t } = useTranslate(); + return ( + + {value || {t('task_placeholder')}} + + ); +}); + +type Props = { + value: Task | null; + onChange?: (task: Task | null) => void; + label?: React.ReactNode; + sx?: SxProps; + className?: string; + project: SimpleProjectModel; +}; + +export const TaskSearchSelect: React.FC = ({ + value, + onChange, + label, + sx, + className, + project, +}) => { + const anchorEl = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + }; + + const handleClick = () => { + setIsOpen(true); + }; + + const handleSelectOrganization = async (task: Task | null) => { + onChange?.(task); + setIsOpen(false); + }; + + return ( + <> + + : ''} + data-cy="assignee-select" + minHeight={false} + label={label} + InputProps={{ + onClick: handleClick, + ref: anchorEl, + fullWidth: true, + sx: { + cursor: 'pointer', + }, + readOnly: true, + inputComponent: FakeInput, + margin: 'dense', + endAdornment: ( + + + + + + ), + }} + /> + + + + + ); +}; diff --git a/webapp/src/component/task/taskSelect/TaskSearchSelectItem.tsx b/webapp/src/component/task/taskSelect/TaskSearchSelectItem.tsx new file mode 100644 index 0000000000..f5b7787906 --- /dev/null +++ b/webapp/src/component/task/taskSelect/TaskSearchSelectItem.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { styled } from '@mui/material'; + +import { Task } from './types'; +import { TaskLabel } from '../TaskLabel'; + +const StyledOrgItem = styled('div')` + display: grid; + grid-auto-flow: column; + gap: 6px; + align-items: center; + text: ${({ theme }) => theme.palette.primaryText}; +`; + +type Props = { + data: Task; +}; + +export const TaskSearchSelectItem: React.FC = ({ data }) => { + return ( + + + + ); +}; diff --git a/webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx b/webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx new file mode 100644 index 0000000000..874df7ec3a --- /dev/null +++ b/webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx @@ -0,0 +1,226 @@ +import React, { useEffect, useState } from 'react'; +import { + MenuItem, + Popover, + Autocomplete, + InputBase, + Box, + styled, + Button, + SxProps, +} from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { useDebounce } from 'use-debounce'; + +import { components } from 'tg.service/apiSchema.generated'; +import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { TaskSearchSelectItem } from './TaskSearchSelectItem'; +import { Task } from './types'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const ITEMS_SEARCH_TREASHOLD = 5; + +const StyledInput = styled(InputBase)` + padding: 5px 4px 3px 16px; + flex-grow: 1; +`; + +const StyledInputWrapper = styled(Box)` + display: flex; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider1}; + padding-right: 4px; +`; + +const StyledWrapper = styled('div')` + display: grid; +`; + +const StyledProgressContainer = styled('div')` + display: flex; + align-items: center; + margin-left: -18px; +`; + +function PopperComponent(props) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { disablePortal, anchorEl, open, ...other } = props; + return ; +} + +function PaperComponent(props) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { disablePortal, anchorEl, open, ...other } = props; + return ; +} + +type Props = { + open: boolean; + onClose: () => void; + onSelect: (value: Task | null) => void; + anchorEl: HTMLElement; + selected: Task | null; + ownedOnly?: boolean; + project: SimpleProjectModel; + sx?: SxProps; + className?: string; +}; + +export const TaskSearchSelectPopover: React.FC = ({ + open, + onClose, + onSelect, + anchorEl, + selected, + ownedOnly, + project, + sx, + className, +}) => { + const [inputValue, setInputValue] = useState(''); + const { t } = useTranslate(); + const [search] = useDebounce(inputValue, 500); + + const query = { + params: { + filterCurrentUserOwner: Boolean(ownedOnly), + search: search || undefined, + filterState: ['IN_PROGRESS '], + }, + size: 20, + sort: ['name'], + }; + + const usersLoadable = useApiInfiniteQuery({ + url: '/v2/projects/{projectId}/tasks', + method: 'get', + path: { projectId: project.id }, + query, + options: { + keepPreviousData: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + path: { projectId: project.id }, + }; + } else { + return null; + } + }, + }, + }); + + const items: Task[] = usersLoadable.data?.pages + .flatMap((page) => page._embedded?.tasks) + .filter(Boolean) as Task[]; + + const [displaySearch, setDisplaySearch] = useState( + undefined + ); + + useEffect(() => { + if (usersLoadable.data && displaySearch === undefined) { + setDisplaySearch( + usersLoadable.data.pages[0].page!.totalElements! > + ITEMS_SEARCH_TREASHOLD + ); + } + }, [usersLoadable.data]); + + return ( + <> + + + x} + loading={usersLoadable.isFetching} + options={items || []} + value={selected} + inputValue={inputValue} + onClose={(_, reason) => reason === 'escape' && onClose()} + clearOnEscape={false} + noOptionsText={t('global_nothing_found')} + loadingText={t('global_loading_text')} + isOptionEqualToValue={(o, v) => o.number === v.number} + onInputChange={(_, value, reason) => + reason === 'input' && setInputValue(value) + } + getOptionLabel={(u) => u.name || ''} + PopperComponent={PopperComponent} + PaperComponent={PaperComponent} + renderOption={(props, option) => ( + + { + onSelect(option); + }} + selected={option.number === selected?.number} + data-cy="task-select-item" + > + + + {usersLoadable.hasNextPage && + option.number === items![items!.length - 1].number && ( + + + + )} + + )} + renderInput={(params) => ( + + + + + ) : undefined + } + /> + + )} + /> + + + + ); +}; diff --git a/webapp/src/component/task/taskSelect/types.ts b/webapp/src/component/task/taskSelect/types.ts new file mode 100644 index 0000000000..e6fe13ee01 --- /dev/null +++ b/webapp/src/component/task/taskSelect/types.ts @@ -0,0 +1,5 @@ +import { components } from 'tg.service/apiSchema.generated'; + +export type Avatar = components['schemas']['Avatar']; + +export type Task = components['schemas']['TaskModel']; diff --git a/webapp/src/component/task/tasksHeader/TasksHeader.tsx b/webapp/src/component/task/tasksHeader/TasksHeader.tsx new file mode 100644 index 0000000000..117f677ecb --- /dev/null +++ b/webapp/src/component/task/tasksHeader/TasksHeader.tsx @@ -0,0 +1,16 @@ +import { TasksHeaderBig } from './TasksHeaderBig'; +import { TasksHeaderCompact } from './TasksHeaderCompact'; + +type Props = React.ComponentProps & { isSmall: boolean }; + +export const TasksHeader = ({ isSmall, ...props }: Props) => { + return ( + <> + {isSmall ? ( + + ) : ( + + )} + + ); +}; diff --git a/webapp/src/component/task/tasksHeader/TasksHeaderBig.tsx b/webapp/src/component/task/tasksHeader/TasksHeaderBig.tsx new file mode 100644 index 0000000000..678a22a99c --- /dev/null +++ b/webapp/src/component/task/tasksHeader/TasksHeaderBig.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { + Box, + Button, + ButtonGroup, + Checkbox, + FormControlLabel, + InputAdornment, + styled, + SxProps, +} from '@mui/material'; +import { useDebounceCallback } from 'usehooks-ts'; + +import { TextField } from 'tg.component/common/TextField'; +import { + BarChartSquare01, + Plus, + Rows03, + SearchSm, +} from '@untitled-ui/icons-react'; +import { useTranslate } from '@tolgee/react'; +import { TaskFilterType } from 'tg.component/task/taskFilter/TaskFilterPopover'; +import { TaskFilter } from 'tg.component/task/taskFilter/TaskFilter'; +import { LabelHint } from 'tg.component/common/LabelHint'; +import { components } from 'tg.service/apiSchema.generated'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const StyledContainer = styled(Box)` + display: flex; + gap: 16px; + justify-content: space-between; +`; + +const StyledToggleButton = styled(Button)` + padding: 4px 8px; +`; + +export type TaskView = 'LIST' | 'BOARD'; + +type Props = { + sx?: SxProps; + className?: string; + onSearchChange: (value: string) => void; + showClosed: boolean; + onShowClosedChange: (value: boolean) => void; + filter: TaskFilterType; + onFilterChange: (value: TaskFilterType) => void; + onAddTask?: () => void; + view: TaskView; + onViewChange: (view: TaskView) => void; + project?: SimpleProjectModel; +}; + +export const TasksHeaderBig = ({ + sx, + className, + onSearchChange, + showClosed, + onShowClosedChange, + filter, + onFilterChange, + onAddTask, + view, + onViewChange, + project, +}: Props) => { + const [localSearch, setLocalSearch] = useState(''); + const onDebouncedSearchChange = useDebounceCallback(onSearchChange, 500); + const { t } = useTranslate(); + + return ( + + + { + setLocalSearch(e.target.value); + onDebouncedSearchChange(e.target.value); + }} + placeholder={t('tasks_search_placeholder')} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + onShowClosedChange(!showClosed)} + control={} + label={ + + {t('tasks_show_closed_label')} + + + + + } + /> + + + + onViewChange('LIST')} + data-cy="tasks-view-list-button" + > + + + onViewChange('BOARD')} + data-cy="tasks-view-table-button" + > + + + + + {onAddTask && ( + + )} + + + ); +}; diff --git a/webapp/src/component/task/tasksHeader/TasksHeaderCompact.tsx b/webapp/src/component/task/tasksHeader/TasksHeaderCompact.tsx new file mode 100644 index 0000000000..2ed88a1cad --- /dev/null +++ b/webapp/src/component/task/tasksHeader/TasksHeaderCompact.tsx @@ -0,0 +1,186 @@ +import { useRef, useState } from 'react'; +import { + Badge, + Box, + Checkbox, + FormControlLabel, + IconButton, + styled, + SxProps, +} from '@mui/material'; +import { useDebounceCallback } from 'usehooks-ts'; +import { FilterLines, Plus, SearchSm, XClose } from '@untitled-ui/icons-react'; +import { useTranslate } from '@tolgee/react'; + +import { + TaskFilterPopover, + TaskFilterType, +} from 'tg.component/task/taskFilter/TaskFilterPopover'; +import { LabelHint } from 'tg.component/common/LabelHint'; +import { filterEmpty } from 'tg.component/task/taskFilter/taskFilterUtils'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { HeaderSearchField } from 'tg.component/layout/HeaderSearchField'; +import { components } from 'tg.service/apiSchema.generated'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +const StyledContainer = styled(Box)` + display: flex; + gap: 16px; + justify-content: space-between; +`; + +const StyledIconButton = styled(IconButton)` + width: 38px; + height: 38px; +`; + +const StyledSearchSpaced = styled('div')` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(0.5)}; + padding-right: ${({ theme }) => theme.spacing(1)}; + flex-grow: 1; + position: relative; +`; + +const StyledSearch = styled(HeaderSearchField)` + min-width: 200px; +`; + +export type TaskView = 'LIST' | 'BOARD'; + +type Props = { + sx?: SxProps; + className?: string; + onSearchChange: (value: string) => void; + showClosed: boolean; + onShowClosedChange: (value: boolean) => void; + filter: TaskFilterType; + onFilterChange: (value: TaskFilterType) => void; + onAddTask?: () => void; + view: TaskView; + onViewChange: (view: TaskView) => void; + project?: SimpleProjectModel; +}; + +export const TasksHeaderCompact = ({ + sx, + className, + onSearchChange, + showClosed, + onShowClosedChange, + filter, + onFilterChange, + onAddTask, + project, +}: Props) => { + const [localSearch, setLocalSearch] = useState(''); + const [searchOpen, setSearchOpen] = useState(false); + const onDebouncedSearchChange = useDebounceCallback(onSearchChange, 500); + const { t } = useTranslate(); + const filtersAnchorEl = useRef(null); + const [filtersOpen, setFiltersOpen] = useState(false); + + const languagesLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/languages', + method: 'get', + path: { projectId: project?.id ?? 0 }, + query: { size: 10000 }, + options: { + enabled: Boolean(project), + keepPreviousData: true, + }, + }); + + const languages = languagesLoadable.data?._embedded?.languages ?? []; + + return ( + + {searchOpen ? ( + + { + setLocalSearch(value); + onDebouncedSearchChange(value); + }} + label={null} + variant="outlined" + placeholder={t('standard_search_label')} + style={{ + height: 35, + maxWidth: 'unset', + width: '100%', + }} + /> + setSearchOpen(false)}> + + + + ) : ( + <> + + + setSearchOpen(true)} + > + + + + + setFiltersOpen(true)} + ref={filtersAnchorEl} + > + + + + {filtersOpen && ( + setFiltersOpen(false)} + value={filter} + onChange={onFilterChange} + anchorEl={filtersAnchorEl.current!} + project={project} + languages={languages} + /> + )} + onShowClosedChange(!showClosed)} + control={} + sx={{ pl: 1 }} + label={ + + {t('tasks_show_closed_label')} + + + + + } + /> + + + {onAddTask && ( + + + + )} + + + )} + + ); +}; diff --git a/webapp/src/component/task/utils.ts b/webapp/src/component/task/utils.ts new file mode 100644 index 0000000000..ac52724dfd --- /dev/null +++ b/webapp/src/component/task/utils.ts @@ -0,0 +1,49 @@ +import { LINKS, PARAMS } from 'tg.constants/links'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; +type TaskModel = components['schemas']['TaskModel']; + +export const getTaskRedirect = ( + project: SimpleProjectModel, + taskNumber: number +) => { + return `${LINKS.GO_TO_PROJECT_TASK.build({ + [PARAMS.PROJECT_ID]: project.id, + })}?task=${taskNumber}`; +}; + +function toFileName(label: string) { + return label.replace(/[\s]+/g, '_').toLowerCase(); +} + +export const useTaskReport = () => { + const reportMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/csv-report', + method: 'get', + fetchOptions: { + rawResponse: true, + }, + }); + + function downloadReport(projectId: number, task: TaskModel) { + reportMutation.mutate( + { + path: { projectId: projectId, taskNumber: task.number }, + }, + { + async onSuccess(result) { + const res = result as unknown as Response; + const data = await res.blob(); + const url = URL.createObjectURL(data); + const a = document.createElement('a'); + a.download = `${toFileName(task.name)}_report.xlsx`; + a.href = url; + a.click(); + }, + } + ); + } + return { downloadReport, isLoading: reportMutation.isLoading }; +}; diff --git a/webapp/src/views/projects/translations/Filters/FiltersMenu.tsx b/webapp/src/component/translation/translationFilters/FiltersMenu.tsx similarity index 69% rename from webapp/src/views/projects/translations/Filters/FiltersMenu.tsx rename to webapp/src/component/translation/translationFilters/FiltersMenu.tsx index 0f8ebb4cfa..54d5f5922a 100644 --- a/webapp/src/views/projects/translations/Filters/FiltersMenu.tsx +++ b/webapp/src/component/translation/translationFilters/FiltersMenu.tsx @@ -3,23 +3,30 @@ import { useTranslate } from '@tolgee/react'; import React, { useEffect } from 'react'; import { CompactMenuItem } from 'tg.component/ListComponents'; -import { useTranslationsActions } from '../context/TranslationsContext'; -import { useActiveFilters } from './useActiveFilters'; +import { getActiveFilters } from './getActiveFilters'; import { useFiltersContent } from './useFiltersContent'; +import { FiltersType } from './tools'; type Props = { - anchorEl: MenuProps['anchorEl']; + filters: FiltersType; + onChange: (value: FiltersType) => void; onClose: () => void; + anchorEl: MenuProps['anchorEl']; + filtersContent: ReturnType; }; -export const FiltersMenu: React.FC = ({ anchorEl, onClose }) => { - const { setFilters } = useTranslationsActions(); - const filtersContent = useFiltersContent(); - const activeFilters = useActiveFilters(); +export const FiltersMenu: React.FC = ({ + anchorEl, + onClose, + onChange, + filtersContent, + filters, +}) => { + const activeFilters = getActiveFilters(filters); const { t } = useTranslate(); const handleClearFilters = () => { - setFilters({}); + onChange({}); }; useEffect(() => { diff --git a/webapp/src/views/projects/translations/Filters/SubmenuMulti.tsx b/webapp/src/component/translation/translationFilters/SubmenuMulti.tsx similarity index 93% rename from webapp/src/views/projects/translations/Filters/SubmenuMulti.tsx rename to webapp/src/component/translation/translationFilters/SubmenuMulti.tsx index d9946d044b..8c84067877 100644 --- a/webapp/src/views/projects/translations/Filters/SubmenuMulti.tsx +++ b/webapp/src/component/translation/translationFilters/SubmenuMulti.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import { ListItemText, Popover } from '@mui/material'; -import { ArrowRight } from '@mui/icons-material'; +import { ArrowRight } from 'tg.component/CustomIcons'; -import { OptionType } from './tools'; -import { SearchSelectMulti } from '../../../../component/searchSelect/SearchSelectMulti'; +import { SearchSelectMulti } from 'tg.component/searchSelect/SearchSelectMulti'; import { CompactMenuItem } from 'tg.component/ListComponents'; +import { OptionType } from './tools'; type Props = { item: OptionType; diff --git a/webapp/src/views/projects/translations/Filters/SubmenuStates.tsx b/webapp/src/component/translation/translationFilters/SubmenuStates.tsx similarity index 97% rename from webapp/src/views/projects/translations/Filters/SubmenuStates.tsx rename to webapp/src/component/translation/translationFilters/SubmenuStates.tsx index 03ad4c5c06..a54a71258e 100644 --- a/webapp/src/views/projects/translations/Filters/SubmenuStates.tsx +++ b/webapp/src/component/translation/translationFilters/SubmenuStates.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Checkbox, ListItemText, Menu, MenuItem, styled } from '@mui/material'; -import { ArrowRight } from '@mui/icons-material'; +import { ArrowRight } from 'tg.component/CustomIcons'; import { TRANSLATION_STATES } from 'tg.constants/translationStates'; import { decodeFilter, OptionType } from './tools'; diff --git a/webapp/src/component/translation/translationFilters/TranslationFilters.tsx b/webapp/src/component/translation/translationFilters/TranslationFilters.tsx new file mode 100644 index 0000000000..331b5263b8 --- /dev/null +++ b/webapp/src/component/translation/translationFilters/TranslationFilters.tsx @@ -0,0 +1,173 @@ +import { T, useTranslate } from '@tolgee/react'; +import { XClose } from '@untitled-ui/icons-react'; +import { + Select, + Typography, + IconButton, + Tooltip, + styled, + SxProps, +} from '@mui/material'; +import { useState, useEffect } from 'react'; + +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { FiltersType, LanguageModel } from './tools'; +import { FilterOptions, useAvailableFilters } from './useAvailableFilters'; +import { FilterType } from './tools'; +import { getActiveFilters } from './getActiveFilters'; +import { useFiltersContent } from './useFiltersContent'; + +const StyledSelect = styled(Select)` + height: 40px; + margin-top: 0px; + margin-bottom: 0px; + display: flex; + align-items: stretch; + width: 200px; + & div:focus { + background-color: transparent; + } + & .MuiSelect-select { + padding-top: 0px; + padding-bottom: 0px; + display: flex; + align-items: center; + overflow: hidden; + position: relative; + } +`; + +const StyledInputContent = styled('div')` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +`; + +const StyledInputText = styled(Typography)` + align-items: center; + justify-content: space-between; + width: 100%; + flex-grow: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const StyledClearButton = styled(IconButton)` + margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +type Props = { + onChange: (value: FiltersType) => void; + value: FiltersType; + selectedLanguages: LanguageModel[]; + placeholder?: React.ReactNode; + filterOptions?: FilterOptions; + sx?: SxProps; + className?: string; +}; + +export const TranslationFilters = ({ + value, + onChange, + selectedLanguages, + placeholder, + filterOptions, + sx, + className, +}: Props) => { + const { t } = useTranslate(); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (isOpen) { + filtersContent.refresh(); + } + }, [isOpen]); + + const activeFilters = getActiveFilters(value); + + const { availableFilters } = useAvailableFilters( + selectedLanguages, + filterOptions + ); + + const findOption = (value: string) => + availableFilters + .map((g) => g.options?.find((o) => o.value === value)) + .filter(Boolean)[0]; + + const filtersContent = useFiltersContent( + value, + onChange, + selectedLanguages, + filterOptions + ); + + const handleClearFilters = (e) => { + onChange({}); + }; + + function getFilterName(value) { + const option = findOption(value); + if (option?.label) { + return option.label; + } + + const parsed = JSON.parse(value) as FilterType; + if (parsed.filter === 'filterNamespace') { + return (parsed.value as string) || t('namespace_default'); + } + } + + return ( + setIsOpen(true)} + onClose={() => setIsOpen(false)} + variant="outlined" + value={activeFilters} + data-cy="translations-filter-select" + renderValue={(value: any) => ( + + + {value.length === 0 + ? placeholder ?? + : (value.length === 1 && getFilterName(value[0])) || ( + + )} + + {Boolean(activeFilters.length) && ( + }> + + + + + )} + + )} + MenuProps={{ + variant: 'menu', + }} + margin="dense" + displayEmpty + multiple + {...{ sx, className }} + > + {filtersContent.options} + + ); +}; diff --git a/webapp/src/views/projects/translations/Filters/useActiveFilters.ts b/webapp/src/component/translation/translationFilters/getActiveFilters.ts similarity index 69% rename from webapp/src/views/projects/translations/Filters/useActiveFilters.ts rename to webapp/src/component/translation/translationFilters/getActiveFilters.ts index edd17d54e9..a51a622fd5 100644 --- a/webapp/src/views/projects/translations/Filters/useActiveFilters.ts +++ b/webapp/src/component/translation/translationFilters/getActiveFilters.ts @@ -1,9 +1,7 @@ -import { useTranslationsSelector } from '../context/TranslationsContext'; import { NON_EXCLUSIVE_FILTERS } from './tools'; +import { FiltersType } from './tools'; -export const useActiveFilters = () => { - const filtersObj = useTranslationsSelector((v) => v.filters); - +export const getActiveFilters = (filtersObj: FiltersType) => { const activeFilters: string[] = []; Object.entries(filtersObj).forEach(([key, value]) => { if (!NON_EXCLUSIVE_FILTERS.includes(key)) { @@ -16,7 +14,7 @@ export const useActiveFilters = () => { ); } } else { - (value as unknown as string[]).forEach((filterVal) => { + (value as unknown as string[])?.forEach((filterVal) => { activeFilters.push( JSON.stringify({ filter: key, diff --git a/webapp/src/views/projects/translations/Filters/tools.ts b/webapp/src/component/translation/translationFilters/tools.ts similarity index 81% rename from webapp/src/views/projects/translations/Filters/tools.ts rename to webapp/src/component/translation/translationFilters/tools.ts index 470986ad94..00d2c85454 100644 --- a/webapp/src/views/projects/translations/Filters/tools.ts +++ b/webapp/src/component/translation/translationFilters/tools.ts @@ -1,4 +1,21 @@ -import { Filters } from '../context/types'; +import { components, operations } from 'tg.service/apiSchema.generated'; + +type TranslationsQueryType = + operations['getTranslations']['parameters']['query']; + +export type LanguageModel = components['schemas']['LanguageModel']; + +export type FiltersType = Pick< + TranslationsQueryType, + | 'filterHasNoScreenshot' + | 'filterHasScreenshot' + | 'filterTranslatedAny' + | 'filterUntranslatedAny' + | 'filterTranslatedInLang' + | 'filterUntranslatedInLang' + | 'filterState' + | 'filterTag' +>; // Filters that can have multiple values export const NON_EXCLUSIVE_FILTERS = [ @@ -32,7 +49,7 @@ export const findGroup = (availableFilters: GroupType[], value: string) => availableFilters.find((g) => g.options?.find((o) => o.value === value)); export const toggleFilter = ( - filtersObj: Filters, + filtersObj: FiltersType, availableFilters: GroupType[], rawValue: string ) => { diff --git a/webapp/src/component/translation/translationFilters/useAvailableFilters.tsx b/webapp/src/component/translation/translationFilters/useAvailableFilters.tsx new file mode 100644 index 0000000000..47f339821e --- /dev/null +++ b/webapp/src/component/translation/translationFilters/useAvailableFilters.tsx @@ -0,0 +1,154 @@ +import { useTranslate } from '@tolgee/react'; + +import { useProject } from 'tg.hooks/useProject'; +import { StateType, TRANSLATION_STATES } from 'tg.constants/translationStates'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { encodeFilter, GroupType } from './tools'; +import { LanguageModel } from './tools'; +import { useStateTranslation } from 'tg.translationTools/useStateTranslation'; + +export type FilterOptions = { + keyRelatedOnly: boolean; +}; + +export const useAvailableFilters = ( + selectedLanguages?: LanguageModel[], + options?: FilterOptions +) => { + const project = useProject(); + const { t } = useTranslate(); + const translateState = useStateTranslation(); + + const tags = useApiQuery({ + url: '/v2/projects/{projectId}/tags', + method: 'get', + path: { projectId: project.id }, + query: { size: 1000 }, + }); + + const namespaces = useApiQuery({ + url: '/v2/projects/{projectId}/used-namespaces', + method: 'get', + path: { projectId: project.id }, + }); + + const availableFilters: GroupType[] = [ + { + name: null, + type: 'multi', + options: [ + { + label: t('translations_filters_heading_tags'), + value: null, + submenu: + tags.data?._embedded?.tags?.map((val) => { + return { + label: val.name, + value: encodeFilter({ + filter: 'filterTag', + value: val.name, + }), + }; + }) || [], + }, + { + label: t('translations_filters_heading_namespaces'), + value: null, + submenu: + namespaces.data?._embedded?.namespaces?.map((val) => { + return { + label: val.name || t('namespace_default'), + value: encodeFilter({ + filter: 'filterNamespace', + value: val.name || '', + }), + }; + }) || [], + }, + ], + }, + ]; + + if (!options?.keyRelatedOnly) { + availableFilters.push( + { + name: t('translations_filters_heading_translations'), + options: [ + { + label: t('translations_filters_missing_translation'), + value: encodeFilter({ + filter: 'filterUntranslatedAny', + value: true, + }), + }, + ], + }, + { + name: null, + options: [ + { + label: t('translations_filters_something_outdated'), + value: encodeFilter({ + filter: 'filterOutdatedLanguage', + value: selectedLanguages?.map((l) => l.tag) || [], + }), + }, + ], + } + ); + } + + availableFilters.push({ + name: t('translations_filters_heading_screenshots'), + options: [ + { + label: t('translations_filters_no_screenshots'), + value: encodeFilter({ + filter: 'filterHasNoScreenshot', + value: true, + }), + }, + { + label: t('translations_filters_with_screenshots'), + value: encodeFilter({ + filter: 'filterHasScreenshot', + value: true, + }), + }, + ], + }); + + if (!options?.keyRelatedOnly) { + availableFilters.push({ + name: t('translations_filters_heading_states'), + type: 'states', + options: + selectedLanguages?.map((language) => { + return { + label: language.name, + value: null, + submenu: Object.entries(TRANSLATION_STATES) + // MACHINE_TRANSLATED is not supported yet + .filter(([key]) => key !== 'MACHINE_TRANSLATED') + .map(([key, value]) => { + return { + label: translateState(key as StateType), + value: encodeFilter({ + filter: 'filterState', + value: `${language.tag},${key}`, + }), + }; + }), + }; + }) || [], + }); + } + + return { + refresh: () => { + tags.refetch(); + namespaces.refetch(); + }, + availableFilters, + }; +}; diff --git a/webapp/src/views/projects/translations/Filters/useFiltersContent.tsx b/webapp/src/component/translation/translationFilters/useFiltersContent.tsx similarity index 77% rename from webapp/src/views/projects/translations/Filters/useFiltersContent.tsx rename to webapp/src/component/translation/translationFilters/useFiltersContent.tsx index 45d0034d50..0dd0158441 100644 --- a/webapp/src/views/projects/translations/Filters/useFiltersContent.tsx +++ b/webapp/src/component/translation/translationFilters/useFiltersContent.tsx @@ -1,34 +1,35 @@ import { Checkbox, ListItemText } from '@mui/material'; -import { - useTranslationsActions, - useTranslationsSelector, -} from '../context/TranslationsContext'; import { SubmenuStates } from './SubmenuStates'; import { SubmenuMulti } from './SubmenuMulti'; -import { useAvailableFilters } from './useAvailableFilters'; +import { FilterOptions, useAvailableFilters } from './useAvailableFilters'; import { toggleFilter } from './tools'; -import { useActiveFilters } from './useActiveFilters'; import { CompactListSubheader, CompactMenuItem, } from 'tg.component/ListComponents'; -import React from 'react'; +import { FiltersType, LanguageModel } from './tools'; +import { getActiveFilters } from './getActiveFilters'; -export const useFiltersContent = () => { +export const useFiltersContent = ( + filtersObj: FiltersType, + onChange: (value: FiltersType) => void, + selectedLanguages?: LanguageModel[], + filterOptions?: FilterOptions +) => { const options: any[] = []; - const { setFilters } = useTranslationsActions(); - const filtersObj = useTranslationsSelector((v) => v.filters); - const selectedLanguages = useTranslationsSelector((v) => v.selectedLanguages); - const activeFilters = useActiveFilters(); + const activeFilters = getActiveFilters(filtersObj); const handleFilterToggle = (rawValue: string) => () => { const newFilters = toggleFilter(filtersObj, availableFilters, rawValue); - setFilters(newFilters); + onChange(newFilters); }; - const { availableFilters, refresh } = useAvailableFilters(selectedLanguages); + const { availableFilters, refresh } = useAvailableFilters( + selectedLanguages, + filterOptions + ); availableFilters.forEach((group, i1) => { if (group.options?.length) { diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index 550d740a71..4f48585ea6 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -7,7 +7,7 @@ import { signUpService } from '../service/SignUpService'; import { checkParamNameIsValid } from '@tginternal/editor'; import { validateObject } from 'tg.fixtures/validateObject'; -type TFunType = TFnType; +type TranslateFunction = TFnType; type AccountType = components['schemas']['PrivateUserAccountModel']['accountType']; @@ -38,7 +38,7 @@ Yup.setLocale({ }); export class Validation { - static readonly USER_PASSWORD = (t: TFunType) => + static readonly USER_PASSWORD = (t: TranslateFunction) => Yup.string().min(8).max(50).required(); static readonly RESET_PASSWORD_REQUEST = Yup.object().shape({ @@ -57,7 +57,7 @@ export class Validation { } }, signUpService.validateEmail); - static readonly SIGN_UP = (t: TFunType, orgRequired: boolean) => + static readonly SIGN_UP = (t: TranslateFunction, orgRequired: boolean) => Yup.object().shape({ password: Validation.USER_PASSWORD(t), name: Yup.string().required(), @@ -93,13 +93,13 @@ export class Validation { : Yup.string().email().required(), }); - static readonly USER_PASSWORD_CHANGE = (t: TFunType) => + static readonly USER_PASSWORD_CHANGE = (t: TranslateFunction) => Yup.object().shape({ currentPassword: Yup.string().max(50).required(), password: Validation.USER_PASSWORD(t), }); - static readonly PASSWORD_RESET = (t: TFunType) => + static readonly PASSWORD_RESET = (t: TranslateFunction) => Yup.object().shape({ password: Validation.USER_PASSWORD(t), }); @@ -153,7 +153,10 @@ export class Validation { static readonly TRANSLATION_TRANSLATION = Yup.string(); static readonly LANGUAGE_NAME = Yup.string().required().max(100); - static readonly LANGUAGE_TAG = (t: TFunType, existingTags?: string[]) => + static readonly LANGUAGE_TAG = ( + t: TranslateFunction, + existingTags?: string[] + ) => Yup.string() .required() .max(20) @@ -168,7 +171,7 @@ export class Validation { static readonly LANGUAGE_ORIGINAL_NAME = Yup.string().required().max(100); static readonly LANGUAGE_FLAG_EMOJI = Yup.string().required().max(20); - static readonly LANGUAGE = (t: TFunType, existingTags?: string[]) => + static readonly LANGUAGE = (t: TranslateFunction, existingTags?: string[]) => Yup.object().shape({ name: Validation.LANGUAGE_NAME, originalName: Validation.LANGUAGE_ORIGINAL_NAME, @@ -225,7 +228,7 @@ export class Validation { } static readonly ORGANIZATION_CREATE_OR_EDIT = ( - t: TFunType, + t: TranslateFunction, slugInitialValue?: string ) => { const slugSyncValidation = Validation.slugValidation(3, 60).required(); @@ -257,7 +260,7 @@ export class Validation { }); }; - static readonly INVITE_DIALOG_PROJECT = (t: TFunType) => + static readonly INVITE_DIALOG_PROJECT = (t: TranslateFunction) => Yup.object({ permission: Yup.string(), permissionLanguages: Yup.array(Yup.string()), @@ -271,7 +274,7 @@ export class Validation { ), }); - static readonly INVITE_DIALOG_ORGANIZATION = (t: TFunType) => + static readonly INVITE_DIALOG_ORGANIZATION = (t: TranslateFunction) => Yup.object({ permission: Yup.string(), type: Yup.string(), @@ -375,7 +378,7 @@ export class Validation { url: Yup.string().required().max(255), }); - static readonly NEW_KEY_FORM = (t: TFnType) => + static readonly NEW_KEY_FORM = (t: TranslateFunction) => Yup.object().shape({ name: Yup.string().required(), pluralParameter: Yup.string().when('isPlural', { @@ -388,7 +391,7 @@ export class Validation { }), }); - static readonly KEY_SETTINGS_FORM = (t: TFnType) => + static readonly KEY_SETTINGS_FORM = (t: TranslateFunction) => Yup.object().shape({ custom: Yup.string().test( 'invalid-custom-values', @@ -396,6 +399,20 @@ export class Validation { validateObject ), }); + + static readonly CREATE_TASK_FORM = (t: TranslateFunction) => + Yup.object().shape({ + name: Yup.string().min(3).required(), + languages: Yup.array(Yup.number()).min( + 1, + t('validation_no_language_selected') + ), + }); + + static readonly UPDATE_TASK_FORM = (t: TranslateFunction) => + Yup.object().shape({ + name: Yup.string().min(3).required(), + }); } let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 603c77e589..27008c7dc3 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -70,6 +70,8 @@ export class LINKS { * Authentication */ + static MY_TASKS = Link.ofRoot('my-tasks'); + static LOGIN = Link.ofRoot('login'); static OAUTH_RESPONSE = Link.ofParent( @@ -293,6 +295,8 @@ export class LINKS { 'single' ); + static PROJECT_TASKS = Link.ofParent(LINKS.PROJECT, 'tasks'); + static PROJECT_EXPORT = Link.ofParent(LINKS.PROJECT, 'export'); static PROJECT_WEBSOCKETS_PREVIEW = Link.ofParent( @@ -356,6 +360,8 @@ export class LINKS { 'activity-detail' ); + static GO_TO_PROJECT_TASK = Link.ofParent(LINKS.PROJECT, 'task-redirect'); + /** * Slack */ diff --git a/webapp/src/ee/PermissionsAdvanced/usePermissionsStructure.ts b/webapp/src/ee/PermissionsAdvanced/usePermissionsStructure.ts index 41a6a617ce..8599730355 100644 --- a/webapp/src/ee/PermissionsAdvanced/usePermissionsStructure.ts +++ b/webapp/src/ee/PermissionsAdvanced/usePermissionsStructure.ts @@ -122,6 +122,17 @@ export const usePermissionsStructure = (options?: Scope[]) => { }, ], }, + { + label: t('permissions_tasks'), + children: [ + { + value: 'tasks.view', + }, + { + value: 'tasks.edit', + }, + ], + }, { value: 'webhooks.manage', }, diff --git a/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx b/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx index b1d5ec7159..799b70f690 100644 --- a/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx +++ b/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx @@ -39,9 +39,12 @@ export const useScopeTranslations = () => { 'webhooks.manage': t('permissions_item_webhooks_manage'), 'content-delivery.manage': t('permissions_item_content_delivery_manage'), 'content-delivery.publish': t('permissions_item_content_delivery_publish'), + 'tasks.view': t('permissions_item_tasks_view'), + 'tasks.edit': t('permissions_item_tasks_edit'), }; return { - getScopeTranslation: (scope: PermissionModelScope) => labels[scope], + getScopeTranslation: (scope: PermissionModelScope) => + labels[scope] ?? scope, }; }; diff --git a/webapp/src/ee/billing/Invoices/InvoiceUsage.tsx b/webapp/src/ee/billing/Invoices/InvoiceUsage.tsx index ff49d0d841..9495031d21 100644 --- a/webapp/src/ee/billing/Invoices/InvoiceUsage.tsx +++ b/webapp/src/ee/billing/Invoices/InvoiceUsage.tsx @@ -5,7 +5,7 @@ import { IconButton, Tooltip, } from '@mui/material'; -import { DataUsage } from '@mui/icons-material'; +import { PieChart01 } from '@untitled-ui/icons-react'; import { FC, useState } from 'react'; import { components } from 'tg.service/billingApiSchema.generated'; import { useTranslate } from '@tolgee/react'; @@ -49,7 +49,7 @@ export const InvoiceUsage: FC<{ aria-label={t('billing_invoices_show_usage_button')} > - + diff --git a/webapp/src/ee/billing/OrganizationBillingTestClockHelperView.tsx b/webapp/src/ee/billing/OrganizationBillingTestClockHelperView.tsx index 721f7373f2..5e8fe6349e 100644 --- a/webapp/src/ee/billing/OrganizationBillingTestClockHelperView.tsx +++ b/webapp/src/ee/billing/OrganizationBillingTestClockHelperView.tsx @@ -48,13 +48,13 @@ export const OrganizationBillingTestClockHelperView: FunctionComponent = () => { const moveMutation = useApiMutation({ url: '/internal/time/{dateTimeString}' as any, method: 'put', - invalidatePrefix: '/internal/test-clock-helper', + invalidatePrefix: '/internal/test-clock-helper' as any, }); const resetMutation = useApiMutation({ url: '/internal/time' as any, method: 'delete', - invalidatePrefix: '/internal/test-clock-helper', + invalidatePrefix: '/internal/test-clock-helper' as any, }); const info = infoLoadable.data; diff --git a/webapp/src/ee/billing/administration/AdministrationCloudPlansView.tsx b/webapp/src/ee/billing/administration/AdministrationCloudPlansView.tsx index d5ca2fbf32..e2f31fe462 100644 --- a/webapp/src/ee/billing/administration/AdministrationCloudPlansView.tsx +++ b/webapp/src/ee/billing/administration/AdministrationCloudPlansView.tsx @@ -17,7 +17,7 @@ import { } from 'tg.service/http/useQueryApi'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; import { Link } from 'react-router-dom'; -import { Delete } from '@mui/icons-material'; +import { Delete } from '@untitled-ui/icons-react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { confirmation } from 'tg.hooks/confirmation'; import { components } from 'tg.service/billingApiSchema.generated'; diff --git a/webapp/src/ee/billing/administration/AdministrationEePlansView.tsx b/webapp/src/ee/billing/administration/AdministrationEePlansView.tsx index bd191aa008..1b27d688ee 100644 --- a/webapp/src/ee/billing/administration/AdministrationEePlansView.tsx +++ b/webapp/src/ee/billing/administration/AdministrationEePlansView.tsx @@ -17,7 +17,7 @@ import { } from 'tg.service/http/useQueryApi'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; import { Link } from 'react-router-dom'; -import { Delete } from '@mui/icons-material'; +import { Delete } from '@untitled-ui/icons-react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { confirmation } from 'tg.hooks/confirmation'; import { components } from 'tg.service/billingApiSchema.generated'; diff --git a/webapp/src/ee/billing/administration/components/CloudPlanForm.tsx b/webapp/src/ee/billing/administration/components/CloudPlanForm.tsx index 3e1838fe13..72d04bc3ab 100644 --- a/webapp/src/ee/billing/administration/components/CloudPlanForm.tsx +++ b/webapp/src/ee/billing/administration/components/CloudPlanForm.tsx @@ -120,6 +120,7 @@ export function CloudPlanForm({ name="type" size="small" fullWidth + minHeight={false} sx={{ flexBasis: '50%' }} data-cy="administration-cloud-plan-field-type" renderValue={(val) => diff --git a/webapp/src/ee/billing/common/usage/ItemRow.tsx b/webapp/src/ee/billing/common/usage/ItemRow.tsx index 36b7bd9b4b..1ace796c42 100644 --- a/webapp/src/ee/billing/common/usage/ItemRow.tsx +++ b/webapp/src/ee/billing/common/usage/ItemRow.tsx @@ -2,7 +2,7 @@ import { components } from 'tg.service/billingApiSchema.generated'; import { useMoneyFormatter, useNumberFormatter } from 'tg.hooks/useLocale'; import { IconButton, TableCell, TableRow, Tooltip } from '@mui/material'; import { useTranslate } from '@tolgee/react'; -import { Download } from '@mui/icons-material'; +import { Download02 } from '@untitled-ui/icons-react'; export const ItemRow = (props: { item: @@ -23,7 +23,7 @@ export const ItemRow = (props: { {props.onDownloadReport && ( - + )} diff --git a/webapp/src/ee/billing/common/usage/UsageDialogButton.tsx b/webapp/src/ee/billing/common/usage/UsageDialogButton.tsx index 9dbe2cf8ae..7d0a930b0e 100644 --- a/webapp/src/ee/billing/common/usage/UsageDialogButton.tsx +++ b/webapp/src/ee/billing/common/usage/UsageDialogButton.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { components } from 'tg.service/billingApiSchema.generated'; import { useTranslate } from '@tolgee/react'; import { DialogContent, DialogTitle, IconButton, Tooltip } from '@mui/material'; -import { DataUsage } from '@mui/icons-material'; +import { PieChart01 } from '@untitled-ui/icons-react'; import Dialog from '@mui/material/Dialog'; import { UsageTable } from './UsageTable'; import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; @@ -26,7 +26,7 @@ export const UsageDialogButton: FC<{ onClick={onOpen} data-cy="billing-estimated-costs-open-button" > - + diff --git a/webapp/src/ee/eeLicense/RefreshButton.tsx b/webapp/src/ee/eeLicense/RefreshButton.tsx index c4cd652db4..a54ef5ec5d 100644 --- a/webapp/src/ee/eeLicense/RefreshButton.tsx +++ b/webapp/src/ee/eeLicense/RefreshButton.tsx @@ -2,7 +2,7 @@ import { T, useTranslate } from '@tolgee/react'; import { useApiMutation } from 'tg.service/http/useQueryApi'; import { useSuccessMessage } from 'tg.hooks/useSuccessMessage'; import { Box, IconButton, Tooltip } from '@mui/material'; -import { Refresh } from '@mui/icons-material'; +import { RefreshCcw01 } from '@untitled-ui/icons-react'; export const RefreshButton = () => { const { t } = useTranslate(); @@ -33,7 +33,7 @@ export const RefreshButton = () => { disabled={refreshMutation.isLoading} size="small" > - + diff --git a/webapp/src/globalContext/useInitialDataService.ts b/webapp/src/globalContext/useInitialDataService.ts index 79289c6512..0ac0249daa 100644 --- a/webapp/src/globalContext/useInitialDataService.ts +++ b/webapp/src/globalContext/useInitialDataService.ts @@ -34,6 +34,21 @@ export const useInitialDataService = () => { }, }); + const [userTasks, setUserTasks] = useState(0); + const userTasksLoadable = useApiQuery({ + url: '/v2/user-tasks', + method: 'get', + query: { size: 1, filterState: ['NEW', 'IN_PROGRESS'] }, + options: { + enabled: Boolean(initialDataLoadable.data?.userInfo), + refetchInterval: 60_000, + }, + }); + + useEffect(() => { + setUserTasks(userTasksLoadable.data?.page?.totalElements ?? 0); + }, [userTasksLoadable.data]); + const [announcement, setAnnouncement] = useState( initialDataLoadable.data?.announcement ); @@ -198,6 +213,7 @@ export const useInitialDataService = () => { : undefined, announcement, isFetching, + userTasks, } : undefined; @@ -211,6 +227,7 @@ export const useInitialDataService = () => { completeGuideStep, finishGuide, setQuickStartOpen, + setUserTasks, }, }; }; diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 812315a0b1..4dc9b92ded 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -10,7 +10,6 @@ import { import { FormatIcu } from '@tolgee/format-icu'; import ReactDOM from 'react-dom'; import { QueryClientProvider } from 'react-query'; -import { ReactQueryDevtools } from 'react-query/devtools'; import { BrowserRouter } from 'react-router-dom'; import { SnackbarProvider } from 'notistack'; @@ -86,7 +85,6 @@ const MainWrapper = () => { - diff --git a/webapp/src/service/TranslationHooks.ts b/webapp/src/service/TranslationHooks.ts index 0f2ed5b461..a5ec49f5af 100644 --- a/webapp/src/service/TranslationHooks.ts +++ b/webapp/src/service/TranslationHooks.ts @@ -42,3 +42,22 @@ export const useDeleteTag = () => url: '/v2/projects/{projectId}/keys/{keyId}/tags/{tagId}', method: 'delete', }); + +export const usePutTask = () => + useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}', + method: 'put', + }); + +export const useFinishTask = () => + useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/finish', + method: 'post', + invalidatePrefix: ['/v2/projects/{projectId}/tasks', '/v2/user-tasks'], + }); + +export const usePutTaskTranslation = () => + useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/keys/{keyId}', + method: 'put', + }); diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index e2ea62dc71..fcc9317be3 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -72,6 +72,17 @@ export interface paths { "/v2/projects/{projectId}/users/{userId}/revoke-access": { put: operations["revokePermission"]; }; + "/v2/projects/{projectId}/tasks/{taskNumber}/keys/{keyId}": { + put: operations["updateTaskKey"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/keys": { + get: operations["getTaskKeys"]; + put: operations["updateTaskKeys"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}": { + get: operations["getTask"]; + put: operations["updateTask"]; + }; "/v2/projects/{projectId}/per-language-auto-translation-settings": { get: operations["getPerLanguageAutoTranslationSettings"]; put: operations["setPerLanguageAutoTranslationSettings"]; @@ -318,12 +329,6 @@ export interface paths { /** Pairs user account with slack account. */ post: operations["userLogin"]; }; - "/v2/public/translator/translate": { - post: operations["translate"]; - }; - "/v2/public/telemetry/report": { - post: operations["report"]; - }; "/v2/public/slack": { post: operations["slackCommand"]; }; @@ -339,26 +344,8 @@ export interface paths { */ post: operations["fetchBotEvent"]; }; - "/v2/public/licensing/subscription": { - post: operations["getMySubscription"]; - }; - "/v2/public/licensing/set-key": { - post: operations["onLicenceSetKey"]; - }; - "/v2/public/licensing/report-usage": { - post: operations["reportUsage"]; - }; - "/v2/public/licensing/report-error": { - post: operations["reportError"]; - }; - "/v2/public/licensing/release-key": { - post: operations["releaseKey"]; - }; - "/v2/public/licensing/prepare-set-key": { - post: operations["prepareSetLicenseKey"]; - }; "/v2/public/business-events/report": { - post: operations["report_1"]; + post: operations["report"]; }; "/v2/public/business-events/identify": { post: operations["identify"]; @@ -377,6 +364,25 @@ export interface paths { /** Sends a test request to the webhook */ post: operations["test"]; }; + "/v2/projects/{projectId}/tasks/{taskNumber}/reopen": { + post: operations["reopenTask"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/finish": { + post: operations["finishTask"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/close": { + post: operations["closeTask"]; + }; + "/v2/projects/{projectId}/tasks/create-multiple": { + post: operations["createTasks"]; + }; + "/v2/projects/{projectId}/tasks/calculate-scope": { + post: operations["calculateScope"]; + }; + "/v2/projects/{projectId}/tasks": { + get: operations["getTasks_1"]; + post: operations["createTask"]; + }; "/v2/projects/{projectId}/keys/info": { /** Returns information about keys. (KeyData, Screenshots, Translation in specified language)If key is not found, it's not included in the response. */ post: operations["getInfo"]; @@ -427,7 +433,7 @@ export interface paths { }; "/v2/projects/{projectId}/start-batch-job/pre-translate-by-tm": { /** Pre-translate provided keys to provided languages by TM. */ - post: operations["translate_1"]; + post: operations["translate"]; }; "/v2/projects/{projectId}/start-batch-job/machine-translate": { /** Translate provided keys to provided languages through primary MT provider. */ @@ -513,7 +519,7 @@ export interface paths { }; "/v2/ee-license/prepare-set-license-key": { /** Get info about the upcoming EE subscription. This will show, how much the subscription will cost when key is applied. */ - post: operations["prepareSetLicenseKey_1"]; + post: operations["prepareSetLicenseKey"]; }; "/v2/api-keys": { get: operations["allByUser"]; @@ -545,6 +551,9 @@ export interface paths { /** Returns all organizations owned only by current user */ get: operations["getAllSingleOwnedOrganizations"]; }; + "/v2/user-tasks": { + get: operations["getTasks"]; + }; "/v2/user-preferences": { get: operations["get"]; }; @@ -593,6 +602,18 @@ export interface paths { /** Returns all used project namespaces. Response contains default (null) namespace if used. */ get: operations["getUsedNamespaces"]; }; + "/v2/projects/{projectId}/tasks/{taskNumber}/per-user-report": { + get: operations["getPerUserReport"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/csv-report": { + get: operations["getCsvReport"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/blocking-tasks": { + get: operations["getBlockingTasks"]; + }; + "/v2/projects/{projectId}/tasks/possible-assignees": { + get: operations["getPossibleAssignees"]; + }; "/v2/projects/{projectId}/namespaces": { get: operations["getAllNamespaces"]; }; @@ -1062,7 +1083,11 @@ export interface components { | "tolgee_account_already_connected" | "slack_not_configured" | "slack_workspace_already_connected" - | "slack_connection_error"; + | "slack_connection_error" + | "email_verification_code_not_valid" + | "task_not_found" + | "task_not_finished" + | "task_not_open"; params?: { [key: string]: unknown }[]; }; ErrorResponseBody: { @@ -1135,14 +1160,6 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; /** * @description List of languages user can translate to. If null, all languages editing is permitted. * @example 200001,200004 @@ -1189,7 +1206,17 @@ export interface components { | "content-delivery.manage" | "content-delivery.publish" | "webhooks.manage" + | "tasks.view" + | "tasks.edit" )[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1263,6 +1290,8 @@ export interface components { | "content-delivery.manage" | "content-delivery.publish" | "webhooks.manage" + | "tasks.view" + | "tasks.edit" )[]; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; @@ -1337,6 +1366,61 @@ export interface components { */ lastExecuted?: number; }; + UpdateTaskKeyRequest: { + done: boolean; + }; + UpdateTaskKeyResponse: { + done: boolean; + taskFinished: boolean; + }; + UpdateTaskKeysRequest: { + addKeys?: number[]; + removeKeys?: number[]; + }; + UpdateTaskRequest: { + name?: string; + description?: string; + /** + * Format: int64 + * @description Due to date in epoch format (milliseconds). + * @example 1661172869000 + */ + dueDate?: number; + assignees?: number[]; + }; + SimpleUserAccountModel: { + /** Format: int64 */ + id: number; + username: string; + name?: string; + avatar?: components["schemas"]["Avatar"]; + deleted: boolean; + }; + TaskModel: { + /** Format: int64 */ + number: number; + name: string; + description: string; + type: "TRANSLATE" | "REVIEW"; + language: components["schemas"]["LanguageModel"]; + /** Format: int64 */ + dueDate?: number; + assignees: components["schemas"]["SimpleUserAccountModel"][]; + /** Format: int64 */ + totalItems: number; + /** Format: int64 */ + doneItems: number; + /** Format: int64 */ + baseWordCount: number; + /** Format: int64 */ + baseCharacterCount: number; + author?: components["schemas"]["SimpleUserAccountModel"]; + /** Format: int64 */ + createdAt?: number; + /** Format: int64 */ + closedAt?: number; + state: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; + }; AutoTranslationSettingsDto: { /** Format: int64 */ languageId?: number; @@ -1718,8 +1802,8 @@ export interface components { secretKey?: string; endpoint: string; signingRegion: string; - enabled?: boolean; contentStorageType?: "S3" | "AZURE"; + enabled?: boolean; }; AzureContentStorageConfigModel: { containerName?: string; @@ -1983,7 +2067,7 @@ export interface components { overrideKeyDescriptions: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; - /** @description If true, only updates keys, skipping the creation of new keys */ + /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; }; ImportSettingsModel: { @@ -1991,18 +2075,9 @@ export interface components { convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; - /** @description If true, only updates keys, skipping the creation of new keys */ + /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; }; - /** @description User who created the comment */ - SimpleUserAccountModel: { - /** Format: int64 */ - id: number; - username: string; - name?: string; - avatar?: components["schemas"]["Avatar"]; - deleted: boolean; - }; TranslationCommentModel: { /** * Format: int64 @@ -2158,17 +2233,17 @@ export interface components { }; RevealedPatModel: { token: string; - /** Format: int64 */ - id: number; description: string; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; + id: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + updatedAt: number; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2304,19 +2379,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; + description: string; /** Format: int64 */ id: number; - projectName: string; - userFullName?: string; - description: string; - username?: string; /** Format: int64 */ projectId: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + username?: string; scopes: string[]; + projectName: string; + userFullName?: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2328,49 +2403,6 @@ export interface components { name: string; oldSlug?: string; }; - ExampleItem: { - source: string; - target: string; - key: string; - keyNamespace?: string; - }; - Metadata: { - examples: components["schemas"]["ExampleItem"][]; - closeItems: components["schemas"]["ExampleItem"][]; - keyDescription?: string; - projectDescription?: string; - languageDescription?: string; - }; - TolgeeTranslateParams: { - text: string; - keyName?: string; - sourceTag: string; - targetTag: string; - metadata?: components["schemas"]["Metadata"]; - formality?: "FORMAL" | "INFORMAL" | "DEFAULT"; - isBatch: boolean; - pluralForms?: { [key: string]: string }; - pluralFormExamples?: { [key: string]: string }; - }; - MtResult: { - translated?: string; - /** Format: int32 */ - price: number; - contextDescription?: string; - }; - TelemetryReportRequest: { - instanceId: string; - /** Format: int64 */ - projectsCount: number; - /** Format: int64 */ - translationsCount: number; - /** Format: int64 */ - languagesCount: number; - /** Format: int64 */ - distinctLanguagesCount: number; - /** Format: int64 */ - usersCount: number; - }; SlackCommandDto: { token?: string; team_id: string; @@ -2383,126 +2415,6 @@ export interface components { trigger_id?: string; team_domain: string; }; - GetMySubscriptionDto: { - licenseKey: string; - instanceId: string; - }; - PlanIncludedUsageModel: { - /** Format: int64 */ - seats: number; - /** Format: int64 */ - translationSlots: number; - /** Format: int64 */ - translations: number; - /** Format: int64 */ - mtCredits: number; - }; - PlanPricesModel: { - perSeat: number; - perThousandTranslations?: number; - perThousandMtCredits?: number; - subscriptionMonthly: number; - subscriptionYearly: number; - }; - SelfHostedEePlanModel: { - /** Format: int64 */ - id: number; - name: string; - public: boolean; - enabledFeatures: ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - | "PROJECT_LEVEL_CONTENT_STORAGES" - | "WEBHOOKS" - | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" - | "AI_PROMPT_CUSTOMIZATION" - | "SLACK_INTEGRATION" - )[]; - prices: components["schemas"]["PlanPricesModel"]; - includedUsage: components["schemas"]["PlanIncludedUsageModel"]; - hasYearlyPrice: boolean; - free: boolean; - }; - SelfHostedEeSubscriptionModel: { - /** Format: int64 */ - id: number; - /** Format: int64 */ - currentPeriodStart?: number; - /** Format: int64 */ - currentPeriodEnd?: number; - currentBillingPeriod: "MONTHLY" | "YEARLY"; - /** Format: int64 */ - createdAt: number; - plan: components["schemas"]["SelfHostedEePlanModel"]; - status: - | "ACTIVE" - | "CANCELED" - | "PAST_DUE" - | "UNPAID" - | "ERROR" - | "KEY_USED_BY_ANOTHER_INSTANCE"; - licenseKey?: string; - estimatedCosts?: number; - }; - SetLicenseKeyLicensingDto: { - licenseKey: string; - /** Format: int64 */ - seats: number; - instanceId: string; - }; - ReportUsageDto: { - licenseKey: string; - /** Format: int64 */ - seats: number; - }; - ReportErrorDto: { - stackTrace: string; - licenseKey: string; - }; - ReleaseKeyDto: { - licenseKey: string; - }; - PrepareSetLicenseKeyDto: { - licenseKey: string; - /** Format: int64 */ - seats: number; - }; - AverageProportionalUsageItemModel: { - total: number; - unusedQuantity: number; - usedQuantity: number; - usedQuantityOverPlan: number; - }; - PrepareSetEeLicenceKeyModel: { - plan: components["schemas"]["SelfHostedEePlanModel"]; - usage: components["schemas"]["UsageModel"]; - }; - SumUsageItemModel: { - total: number; - /** Format: int64 */ - unusedQuantity: number; - /** Format: int64 */ - usedQuantity: number; - /** Format: int64 */ - usedQuantityOverPlan: number; - }; - UsageModel: { - subscriptionPrice?: number; - /** @description Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */ - appliedStripeCredits?: number; - seats: components["schemas"]["AverageProportionalUsageItemModel"]; - translations: components["schemas"]["AverageProportionalUsageItemModel"]; - credits?: components["schemas"]["SumUsageItemModel"]; - total: number; - }; BusinessEventReportRequest: { eventName: string; anonymousUserId?: string; @@ -2533,6 +2445,43 @@ export interface components { WebhookTestResponse: { success: boolean; }; + CreateMultipleTasksRequest: { + tasks: components["schemas"]["CreateTaskRequest"][]; + }; + CreateTaskRequest: { + name: string; + description: string; + type: "TRANSLATE" | "REVIEW"; + /** + * Format: int64 + * @description Due to date in epoch format (milliseconds). + * @example 1661172869000 + */ + dueDate?: number; + /** + * Format: int64 + * @description Id of language, this task is attached to. + * @example 1 + */ + languageId: number; + assignees: number[]; + keys: number[]; + state?: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; + }; + CalculateScopeRequest: { + /** Format: int64 */ + language: number; + type: "TRANSLATE" | "REVIEW"; + keys: number[]; + }; + KeysScopeView: { + /** Format: int64 */ + wordCount: number; + /** Format: int64 */ + keyCount: number; + /** Format: int64 */ + characterCount: number; + }; GetKeysRequestDto: { keys: components["schemas"]["KeyDefinitionDto"][]; /** @description Tags to return language translations in */ @@ -2867,7 +2816,11 @@ export interface components { | "tolgee_account_already_connected" | "slack_not_configured" | "slack_workspace_already_connected" - | "slack_connection_error"; + | "slack_connection_error" + | "email_verification_code_not_valid" + | "task_not_found" + | "task_not_finished" + | "task_not_open"; params?: { [key: string]: unknown }[]; }; UntagKeysRequest: { @@ -3063,7 +3016,7 @@ export interface components { overrideKeyDescriptions: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; - /** @description If true, only updates keys, skipping the creation of new keys */ + /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; /** @description Definition of mapping for each file to import. */ fileMappings: components["schemas"]["ImportFileMapping"][]; @@ -3304,9 +3257,81 @@ export interface components { createdAt: string; location?: string; }; - CreateApiKeyDto: { + AverageProportionalUsageItemModel: { + total: number; + unusedQuantity: number; + usedQuantity: number; + usedQuantityOverPlan: number; + }; + PlanIncludedUsageModel: { /** Format: int64 */ - projectId: number; + seats: number; + /** Format: int64 */ + translationSlots: number; + /** Format: int64 */ + translations: number; + /** Format: int64 */ + mtCredits: number; + }; + PlanPricesModel: { + perSeat: number; + perThousandTranslations?: number; + perThousandMtCredits?: number; + subscriptionMonthly: number; + subscriptionYearly: number; + }; + PrepareSetEeLicenceKeyModel: { + plan: components["schemas"]["SelfHostedEePlanModel"]; + usage: components["schemas"]["UsageModel"]; + }; + SelfHostedEePlanModel: { + /** Format: int64 */ + id: number; + name: string; + public: boolean; + enabledFeatures: ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + | "PROJECT_LEVEL_CONTENT_STORAGES" + | "WEBHOOKS" + | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" + | "AI_PROMPT_CUSTOMIZATION" + | "SLACK_INTEGRATION" + )[]; + prices: components["schemas"]["PlanPricesModel"]; + includedUsage: components["schemas"]["PlanIncludedUsageModel"]; + hasYearlyPrice: boolean; + free: boolean; + }; + SumUsageItemModel: { + total: number; + /** Format: int64 */ + unusedQuantity: number; + /** Format: int64 */ + usedQuantity: number; + /** Format: int64 */ + usedQuantityOverPlan: number; + }; + UsageModel: { + subscriptionPrice?: number; + /** @description Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */ + appliedStripeCredits?: number; + seats: components["schemas"]["AverageProportionalUsageItemModel"]; + translations: components["schemas"]["AverageProportionalUsageItemModel"]; + credits?: components["schemas"]["SumUsageItemModel"]; + total: number; + }; + CreateApiKeyDto: { + /** Format: int64 */ + projectId: number; scopes: string[]; /** @description Description of the project API key */ description?: string; @@ -3348,6 +3373,48 @@ export interface components { organizations?: components["schemas"]["SimpleOrganizationModel"][]; }; }; + PagedModelTaskWithProjectModel: { + _embedded?: { + tasks?: components["schemas"]["TaskWithProjectModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + SimpleProjectModel: { + /** Format: int64 */ + id: number; + name: string; + description?: string; + slug?: string; + avatar?: components["schemas"]["Avatar"]; + baseLanguage?: components["schemas"]["LanguageModel"]; + icuPlaceholders: boolean; + }; + TaskWithProjectModel: { + /** Format: int64 */ + number: number; + name: string; + description: string; + type: "TRANSLATE" | "REVIEW"; + language: components["schemas"]["LanguageModel"]; + /** Format: int64 */ + dueDate?: number; + assignees: components["schemas"]["SimpleUserAccountModel"][]; + /** Format: int64 */ + totalItems: number; + /** Format: int64 */ + doneItems: number; + /** Format: int64 */ + baseWordCount: number; + /** Format: int64 */ + baseCharacterCount: number; + author?: components["schemas"]["SimpleUserAccountModel"]; + /** Format: int64 */ + createdAt?: number; + /** Format: int64 */ + closedAt?: number; + state: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; + project: components["schemas"]["SimpleProjectModel"]; + }; UserPreferencesModel: { language?: string; /** Format: int64 */ @@ -3387,7 +3454,9 @@ export interface components { | "translations.batch-machine" | "content-delivery.manage" | "content-delivery.publish" - | "webhooks.manage"; + | "webhooks.manage" + | "tasks.view" + | "tasks.edit"; requires: components["schemas"]["HierarchyItem"][]; }; MachineTranslationProviderModel: { @@ -3468,22 +3537,22 @@ export interface components { | "SLACK_INTEGRATION" )[]; quickStart?: components["schemas"]["QuickStartModel"]; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ id: number; - basePermissions: components["schemas"]["PermissionModel"]; - /** @example btforg */ - slug: string; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; + basePermissions: components["schemas"]["PermissionModel"]; avatar?: components["schemas"]["Avatar"]; + /** @example btforg */ + slug: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3544,9 +3613,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { + description?: string; name: string; displayName?: string; - description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3595,6 +3664,30 @@ export interface components { */ name?: string; }; + TaskPerUserReportModel: { + user: components["schemas"]["SimpleUserAccountModel"]; + /** Format: int64 */ + doneItems: number; + /** Format: int64 */ + baseCharacterCount: number; + /** Format: int64 */ + baseWordCount: number; + }; + TaskKeysResponse: { + keys: number[]; + }; + PagedModelSimpleUserAccountModel: { + _embedded?: { + users?: components["schemas"]["SimpleUserAccountModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + PagedModelTaskModel: { + _embedded?: { + tasks?: components["schemas"]["TaskModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; PagedModelNamespaceModel: { _embedded?: { namespaces?: components["schemas"]["NamespaceModel"][]; @@ -3617,23 +3710,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { + description?: string; name: string; /** Format: int64 */ id: number; - namespace?: string; - description?: string; baseTranslation?: string; translation?: string; + namespace?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; + description?: string; name: string; /** Format: int64 */ id: number; - namespace?: string; - description?: string; baseTranslation?: string; translation?: string; + namespace?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -3760,7 +3853,14 @@ export interface components { | "WEBHOOK_CONFIG_CREATE" | "WEBHOOK_CONFIG_UPDATE" | "WEBHOOK_CONFIG_DELETE" - | "COMPLEX_TAG_OPERATION"; + | "COMPLEX_TAG_OPERATION" + | "TASKS_CREATE" + | "TASK_CREATE" + | "TASK_UPDATE" + | "TASK_KEYS_UPDATE" + | "TASK_FINISH" + | "TASK_CLOSE" + | "TASK_REOPEN"; author?: components["schemas"]["ProjectActivityAuthorModel"]; modifiedEntities?: { [key: string]: components["schemas"]["ModifiedEntityModel"][]; @@ -3928,6 +4028,17 @@ export interface components { SelectAllResponse: { ids: number[]; }; + /** @description Tasks related to this key */ + KeyTaskViewModel: { + /** Format: int64 */ + number: number; + /** Format: int64 */ + languageId: number; + languageTag: string; + done: boolean; + userAssigned: boolean; + type: "TRANSLATE" | "REVIEW"; + }; KeyWithTranslationsModel: { /** * Format: int64 @@ -3992,6 +4103,8 @@ export interface components { translations: { [key: string]: components["schemas"]["TranslationViewModel"]; }; + /** @description Tasks related to this key */ + tasks?: components["schemas"]["KeyTaskViewModel"][]; }; KeysWithTranslationsPageModel: { _embedded?: { @@ -4177,17 +4290,17 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - /** Format: int64 */ - id: number; description: string; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; + id: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + updatedAt: number; }; PagedModelOrganizationModel: { _embedded?: { @@ -4289,16 +4402,6 @@ export interface components { }; page?: components["schemas"]["PageMetadata"]; }; - SimpleProjectModel: { - /** Format: int64 */ - id: number; - name: string; - description?: string; - slug?: string; - avatar?: components["schemas"]["Avatar"]; - baseLanguage?: components["schemas"]["LanguageModel"]; - icuPlaceholders: boolean; - }; UserAccountWithOrganizationRoleModel: { /** Format: int64 */ id: number; @@ -4314,19 +4417,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; + description: string; /** Format: int64 */ id: number; - projectName: string; - userFullName?: string; - description: string; - username?: string; /** Format: int64 */ projectId: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + username?: string; scopes: string[]; + projectName: string; + userFullName?: string; }; PagedModelUserAccountModel: { _embedded?: { @@ -5532,9 +5635,11 @@ export interface operations { }; }; }; - getPerLanguageAutoTranslationSettings: { + updateTaskKey: { parameters: { path: { + taskNumber: number; + keyId: number; projectId: number; }; }; @@ -5542,7 +5647,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; + "application/json": components["schemas"]["UpdateTaskKeyResponse"]; }; }; /** Bad Request */ @@ -5578,10 +5683,16 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTaskKeyRequest"]; + }; + }; }; - setPerLanguageAutoTranslationSettings: { + getTaskKeys: { parameters: { path: { + taskNumber: number; projectId: number; }; }; @@ -5589,7 +5700,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; + "application/json": components["schemas"]["TaskKeysResponse"]; }; }; /** Bad Request */ @@ -5625,26 +5736,17 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["AutoTranslationSettingsDto"][]; - }; - }; }; - update_1: { + updateTaskKeys: { parameters: { path: { - id: number; + taskNumber: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["NamespaceModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -5680,13 +5782,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UpdateNamespaceDto"]; + "application/json": components["schemas"]["UpdateTaskKeysRequest"]; }; }; }; - getMachineTranslationSettings: { + getTask: { parameters: { path: { + taskNumber: number; projectId: number; }; }; @@ -5694,7 +5797,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -5731,9 +5834,10 @@ export interface operations { }; }; }; - setMachineTranslationSettings: { + updateTask: { parameters: { path: { + taskNumber: number; projectId: number; }; }; @@ -5741,7 +5845,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -5779,15 +5883,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetMachineTranslationSettingsDto"]; + "application/json": components["schemas"]["UpdateTaskRequest"]; }; }; }; - /** Returns languages, in which key is disabled */ - getDisabledLanguages: { + getPerLanguageAutoTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -5795,7 +5897,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageModel"]; + "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -5832,11 +5934,9 @@ export interface operations { }; }; }; - /** Sets languages, in which key is disabled */ - setDisabledLanguages: { + setPerLanguageAutoTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -5844,7 +5944,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageModel"]; + "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -5882,12 +5982,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetDisabledLanguagesRequest"]; + "application/json": components["schemas"]["AutoTranslationSettingsDto"][]; }; }; }; - /** Edits key name, translations, tags, screenshots, and other data */ - complexEdit: { + update_1: { parameters: { path: { id: number; @@ -5898,7 +5997,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyWithDataModel"]; + "application/json": components["schemas"]["NamespaceModel"]; }; }; /** Bad Request */ @@ -5936,14 +6035,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ComplexEditKeyDto"]; + "application/json": components["schemas"]["UpdateNamespaceDto"]; }; }; }; - get_6: { + getMachineTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -5951,7 +6049,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyModel"]; + "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; }; }; /** Bad Request */ @@ -5988,10 +6086,9 @@ export interface operations { }; }; }; - edit: { + setMachineTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -5999,7 +6096,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyModel"]; + "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; }; }; /** Bad Request */ @@ -6037,13 +6134,15 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["EditKeyDto"]; + "application/json": components["schemas"]["SetMachineTranslationSettingsDto"]; }; }; }; - inviteUser: { + /** Returns languages, in which key is disabled */ + getDisabledLanguages: { parameters: { path: { + id: number; projectId: number; }; }; @@ -6051,7 +6150,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ProjectInvitationModel"]; + "application/json": components["schemas"]["CollectionModelLanguageModel"]; }; }; /** Bad Request */ @@ -6087,16 +6186,12 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ProjectInviteUserDto"]; - }; - }; }; - get_8: { + /** Sets languages, in which key is disabled */ + setDisabledLanguages: { parameters: { path: { - contentStorageId: number; + id: number; projectId: number; }; }; @@ -6104,7 +6199,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentStorageModel"]; + "application/json": components["schemas"]["CollectionModelLanguageModel"]; }; }; /** Bad Request */ @@ -6140,11 +6235,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SetDisabledLanguagesRequest"]; + }; + }; }; - update_3: { + /** Edits key name, translations, tags, screenshots, and other data */ + complexEdit: { parameters: { path: { - contentStorageId: number; + id: number; projectId: number; }; }; @@ -6152,7 +6253,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentStorageModel"]; + "application/json": components["schemas"]["KeyWithDataModel"]; }; }; /** Bad Request */ @@ -6190,7 +6291,261 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContentStorageRequest"]; + "application/json": components["schemas"]["ComplexEditKeyDto"]; + }; + }; + }; + get_6: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["KeyModel"]; + }; + }; + /** 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"]; + }; + }; + }; + }; + edit: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["KeyModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EditKeyDto"]; + }; + }; + }; + inviteUser: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["ProjectInvitationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectInviteUserDto"]; + }; + }; + }; + get_8: { + parameters: { + path: { + contentStorageId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["ContentStorageModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + update_3: { + parameters: { + path: { + contentStorageId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["ContentStorageModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContentStorageRequest"]; }; }; }; @@ -7599,6 +7954,8 @@ export interface operations { filterRevisionId?: number[]; /** Select only keys which were not successfully translated by batch job with provided id */ filterFailedKeysOfJob?: number; + /** Select only keys which are in specified task */ + filterTaskNumber?: number[]; /** Zero-based page index (0..N) */ page?: number; /** The size of the page to be returned */ @@ -9626,12 +9983,18 @@ export interface operations { }; }; }; - translate: { - responses: { + slackCommand: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; + responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["MtResult"]; + "application/json": string; }; }; /** Bad Request */ @@ -9669,11 +10032,21 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TolgeeTranslateParams"]; + "application/json": { + payload?: components["schemas"]["SlackCommandDto"]; + body?: string; + }; }; }; }; - report: { + /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ + onInteractivityEvent: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ 200: unknown; @@ -9712,11 +10085,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TelemetryReportRequest"]; + "application/json": string; }; }; }; - slackCommand: { + /** + * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. + * + * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. + */ + fetchBotEvent: { parameters: { header: { "X-Slack-Signature": string; @@ -9727,7 +10105,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": string; + "application/json": { [key: string]: unknown }; }; }; /** Bad Request */ @@ -9765,21 +10143,11 @@ export interface operations { }; requestBody: { content: { - "application/json": { - payload?: components["schemas"]["SlackCommandDto"]; - body?: string; - }; + "application/json": string; }; }; }; - /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ - onInteractivityEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + report: { responses: { /** OK */ 200: unknown; @@ -9818,29 +10186,14 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["BusinessEventReportRequest"]; }; }; }; - /** - * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. - * - * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. - */ - fetchBotEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + identify: { responses: { /** OK */ - 200: { - content: { - "application/json": { [key: string]: unknown }; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9876,16 +10229,32 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["IdentifyRequest"]; }; }; }; - getMySubscription: { + /** Returns all projects where current user has any permission */ + getAll: { + parameters: { + query: { + /** Filter projects by id */ + filterId?: number[]; + /** Filter projects without id */ + filterNotId?: number[]; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + "application/hal+json": components["schemas"]["PagedModelProjectModel"]; }; }; /** Bad Request */ @@ -9921,18 +10290,14 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["GetMySubscriptionDto"]; - }; - }; }; - onLicenceSetKey: { + /** Creates a new project with languages and initial settings. */ + createProject: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + "application/json": components["schemas"]["ProjectModel"]; }; }; /** Bad Request */ @@ -9970,57 +10335,31 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetLicenseKeyLicensingDto"]; + "application/json": components["schemas"]["CreateProjectRequest"]; }; }; }; - reportUsage: { - responses: { - /** OK */ - 200: unknown; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; + list: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ReportUsageDto"]; + path: { + projectId: number; }; }; - }; - reportError: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["PagedModelWebhookConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10054,16 +10393,20 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ReportErrorDto"]; + }; + create: { + parameters: { + path: { + projectId: number; }; }; - }; - releaseKey: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["WebhookConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10099,16 +10442,23 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ReleaseKeyDto"]; + "application/json": components["schemas"]["WebhookConfigRequest"]; }; }; }; - prepareSetLicenseKey: { + /** Sends a test request to the webhook */ + test: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PrepareSetEeLicenceKeyModel"]; + "application/json": components["schemas"]["WebhookTestResponse"]; }; }; /** Bad Request */ @@ -10144,16 +10494,21 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["PrepareSetLicenseKeyDto"]; + }; + reopenTask: { + parameters: { + path: { + taskNumber: number; + projectId: number; }; }; - }; - report_1: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TaskModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10187,16 +10542,21 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["BusinessEventReportRequest"]; + }; + finishTask: { + parameters: { + path: { + taskNumber: number; + projectId: number; }; }; - }; - identify: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["TaskModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10230,30 +10590,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["IdentifyRequest"]; - }; - }; }; - /** Returns all projects where current user has any permission */ - getAll: { + closeTask: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - search?: string; + path: { + taskNumber: number; + projectId: number; }; }; responses: { /** OK */ 200: { content: { - "application/hal+json": components["schemas"]["PagedModelProjectModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -10290,15 +10639,24 @@ export interface operations { }; }; }; - /** Creates a new project with languages and initial settings. */ - createProject: { + createTasks: { + parameters: { + query: { + filterState?: ( + | "UNTRANSLATED" + | "TRANSLATED" + | "REVIEWED" + | "DISABLED" + )[]; + filterOutdated?: boolean; + }; + path: { + projectId: number; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ProjectModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10334,19 +10692,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateProjectRequest"]; + "application/json": components["schemas"]["CreateMultipleTasksRequest"]; }; }; }; - list: { + calculateScope: { parameters: { query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; + filterState?: ( + | "UNTRANSLATED" + | "TRANSLATED" + | "REVIEWED" + | "DISABLED" + )[]; + filterOutdated?: boolean; }; path: { projectId: number; @@ -10356,7 +10715,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelWebhookConfigModel"]; + "application/json": components["schemas"]["KeysScopeView"]; }; }; /** Bad Request */ @@ -10392,9 +10751,45 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["CalculateScopeRequest"]; + }; + }; }; - create: { + getTasks_1: { parameters: { + query: { + /** Filter tasks by state */ + filterState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks without state */ + filterNotState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks by assignee */ + filterAssignee?: number[]; + /** Filter tasks by type */ + filterType?: ("TRANSLATE" | "REVIEW")[]; + /** Filter tasks by id */ + filterId?: number[]; + /** Filter tasks without id */ + filterNotId?: number[]; + /** Filter tasks by project */ + filterProject?: number[]; + /** Filter tasks without project */ + filterNotProject?: number[]; + /** Filter tasks by language */ + filterLanguage?: number[]; + /** Filter tasks by key */ + filterKey?: number[]; + /** Exclude "done" tasks which are older than specified timestamp */ + filterDoneMinClosedAt?: number; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; path: { projectId: number; }; @@ -10403,7 +10798,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["WebhookConfigModel"]; + "application/json": components["schemas"]["PagedModelTaskModel"]; }; }; /** Bad Request */ @@ -10439,17 +10834,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookConfigRequest"]; + }; + createTask: { + parameters: { + query: { + filterState?: ( + | "UNTRANSLATED" + | "TRANSLATED" + | "REVIEWED" + | "DISABLED" + )[]; + filterOutdated?: boolean; }; - }; - }; - /** Sends a test request to the webhook */ - test: { - parameters: { path: { - id: number; projectId: number; }; }; @@ -10457,7 +10854,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["WebhookTestResponse"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -10493,6 +10890,11 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTaskRequest"]; + }; + }; }; /** Returns information about keys. (KeyData, Screenshots, Translation in specified language)If key is not found, it's not included in the response. */ getInfo: { @@ -11386,7 +11788,7 @@ export interface operations { }; }; /** Pre-translate provided keys to provided languages by TM. */ - translate_1: { + translate: { parameters: { path: { projectId: number; @@ -12392,6 +12794,10 @@ export interface operations { size?: number; /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ sort?: string[]; + /** Filter languages by id */ + filterId?: number[]; + /** Filter languages without id */ + filterNotId?: number[]; }; }; responses: { @@ -12897,7 +13303,7 @@ export interface operations { }; }; /** Get info about the upcoming EE subscription. This will show, how much the subscription will cost when key is applied. */ - prepareSetLicenseKey_1: { + prepareSetLicenseKey: { responses: { /** OK */ 200: { @@ -13351,6 +13757,81 @@ export interface operations { }; }; }; + getTasks: { + parameters: { + query: { + /** Filter tasks by state */ + filterState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks without state */ + filterNotState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks by assignee */ + filterAssignee?: number[]; + /** Filter tasks by type */ + filterType?: ("TRANSLATE" | "REVIEW")[]; + /** Filter tasks by id */ + filterId?: number[]; + /** Filter tasks without id */ + filterNotId?: number[]; + /** Filter tasks by project */ + filterProject?: number[]; + /** Filter tasks without project */ + filterNotProject?: number[]; + /** Filter tasks by language */ + filterLanguage?: number[]; + /** Filter tasks by key */ + filterKey?: number[]; + /** Exclude "done" tasks which are older than specified timestamp */ + filterDoneMinClosedAt?: number; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelTaskWithProjectModel"]; + }; + }; + /** 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"]; + }; + }; + }; + }; get: { responses: { /** OK */ @@ -13575,6 +14056,8 @@ export interface operations { | "content-delivery.manage" | "content-delivery.publish" | "webhooks.manage" + | "tasks.view" + | "tasks.edit" )[]; }; }; @@ -13940,6 +14423,216 @@ export interface operations { }; }; }; + getPerUserReport: { + parameters: { + path: { + taskNumber: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["TaskPerUserReportModel"][]; + }; + }; + /** 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"]; + }; + }; + }; + }; + getCsvReport: { + parameters: { + path: { + taskNumber: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": string; + }; + }; + /** 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"]; + }; + }; + }; + }; + getBlockingTasks: { + parameters: { + path: { + taskNumber: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": number[]; + }; + }; + /** 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"]; + }; + }; + }; + }; + getPossibleAssignees: { + parameters: { + query: { + /** Filter users by id */ + filterId?: number[]; + /** Filter only users that have at least following scopes */ + filterMinimalScope?: string; + /** Filter only users that can view language */ + filterViewLanguageId?: number; + /** Filter only users that can edit language */ + filterEditLanguageId?: number; + /** Filter only users that can edit state of language */ + filterStateLanguageId?: number; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelSimpleUserAccountModel"]; + }; + }; + /** 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"]; + }; + }; + }; + }; getAllNamespaces: { parameters: { query: { @@ -15208,6 +15901,8 @@ export interface operations { filterRevisionId?: number[]; /** Select only keys which were not successfully translated by batch job with provided id */ filterFailedKeysOfJob?: number; + /** Select only keys which are in specified task */ + filterTaskNumber?: number[]; }; path: { projectId: number; @@ -15306,6 +16001,8 @@ export interface operations { filterRevisionId?: number[]; /** Select only keys which were not successfully translated by batch job with provided id */ filterFailedKeysOfJob?: number; + /** Select only keys which are in specified task */ + filterTaskNumber?: number[]; }; path: { projectId: number; diff --git a/webapp/src/service/http/useQueryApi.ts b/webapp/src/service/http/useQueryApi.ts index 161c6267bf..c89e831807 100644 --- a/webapp/src/service/http/useQueryApi.ts +++ b/webapp/src/service/http/useQueryApi.ts @@ -47,6 +47,13 @@ export type InfiniteQueryProps< >; } & RequestParamsType; +type Split = S extends `${infer Prefix}/${infer Rest}` + ? Prefix | `${Prefix}/${Split}` + : S; + +// Create a union of all possible prefixes for all paths +type Prefix = Split | '/'; + export type MutationProps< Url extends keyof Paths, Method extends keyof Paths[Url], @@ -60,7 +67,7 @@ export type MutationProps< ApiError, RequestParamsType >; - invalidatePrefix?: string; + invalidatePrefix?: Prefix | Prefix[]; }; export const useApiInfiniteQuery = < @@ -128,7 +135,7 @@ function autoErrorHandling( } function getApiMutationOptions( - invalidatePrefix: string | undefined, + invalidatePrefix: undefined | string | string[], queryClient: QueryClient ) { return (options: UseQueryOptions | undefined) => ({ @@ -217,8 +224,18 @@ export const matchUrlPrefix = (prefix: string) => { }; }; -export const invalidateUrlPrefix = (queryClient: QueryClient, prefix: string) => - queryClient.invalidateQueries(matchUrlPrefix(prefix)); +export const invalidateUrlPrefix = ( + queryClient: QueryClient, + prefix: string | string[] +) => { + if (typeof prefix === 'string') { + queryClient.invalidateQueries(matchUrlPrefix(prefix)); + } else if (Array.isArray(prefix)) { + prefix.forEach((p) => { + queryClient.invalidateQueries(matchUrlPrefix(p)); + }); + } +}; export const useBillingApiQuery = < Url extends keyof billingPaths, diff --git a/webapp/src/svgs/icons/2lines-vertical.svg b/webapp/src/svgs/icons/2lines-vertical.svg new file mode 100644 index 0000000000..06ef89b0b5 --- /dev/null +++ b/webapp/src/svgs/icons/2lines-vertical.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/arrow-drop-down.svg b/webapp/src/svgs/icons/arrow-drop-down.svg new file mode 100644 index 0000000000..f788cfc966 --- /dev/null +++ b/webapp/src/svgs/icons/arrow-drop-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/arrow-right.svg b/webapp/src/svgs/icons/arrow-right.svg new file mode 100644 index 0000000000..9a75032225 --- /dev/null +++ b/webapp/src/svgs/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/back-translation.svg b/webapp/src/svgs/icons/back-translation.svg new file mode 100644 index 0000000000..b74a38c24b --- /dev/null +++ b/webapp/src/svgs/icons/back-translation.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/camera-sad.svg b/webapp/src/svgs/icons/camera-sad.svg new file mode 100644 index 0000000000..cb152926df --- /dev/null +++ b/webapp/src/svgs/icons/camera-sad.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/celebration.svg b/webapp/src/svgs/icons/celebration.svg new file mode 100644 index 0000000000..e433ba6db8 --- /dev/null +++ b/webapp/src/svgs/icons/celebration.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/check-box-outline-blank.svg b/webapp/src/svgs/icons/check-box-outline-blank.svg new file mode 100644 index 0000000000..435a250bf4 --- /dev/null +++ b/webapp/src/svgs/icons/check-box-outline-blank.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/check-circle-dash.svg b/webapp/src/svgs/icons/check-circle-dash.svg new file mode 100644 index 0000000000..bdee743be4 --- /dev/null +++ b/webapp/src/svgs/icons/check-circle-dash.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/filter-lines2.svg b/webapp/src/svgs/icons/filter-lines2.svg new file mode 100644 index 0000000000..5f3e48ee8e --- /dev/null +++ b/webapp/src/svgs/icons/filter-lines2.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/github.svg b/webapp/src/svgs/icons/github.svg new file mode 100644 index 0000000000..ddc9dbcc24 --- /dev/null +++ b/webapp/src/svgs/icons/github.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/google.svg b/webapp/src/svgs/icons/google.svg new file mode 100644 index 0000000000..31f1e72708 --- /dev/null +++ b/webapp/src/svgs/icons/google.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/integration.svg b/webapp/src/svgs/icons/integration.svg new file mode 100644 index 0000000000..499d5dad7c --- /dev/null +++ b/webapp/src/svgs/icons/integration.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/mt.svg b/webapp/src/svgs/icons/mt.svg new file mode 100644 index 0000000000..0fbf89aa7b --- /dev/null +++ b/webapp/src/svgs/icons/mt.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/other-languages.svg b/webapp/src/svgs/icons/other-languages.svg new file mode 100644 index 0000000000..2e323b45bd --- /dev/null +++ b/webapp/src/svgs/icons/other-languages.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/preview.svg b/webapp/src/svgs/icons/preview.svg new file mode 100644 index 0000000000..eb8e4c4ff8 --- /dev/null +++ b/webapp/src/svgs/icons/preview.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/rocket-filled.svg b/webapp/src/svgs/icons/rocket-filled.svg new file mode 100644 index 0000000000..5dfd784f28 --- /dev/null +++ b/webapp/src/svgs/icons/rocket-filled.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/rocket.svg b/webapp/src/svgs/icons/rocket.svg index e582fab5af..b65615a051 100644 --- a/webapp/src/svgs/icons/rocket.svg +++ b/webapp/src/svgs/icons/rocket.svg @@ -1,4 +1,6 @@ - + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/stars.svg b/webapp/src/svgs/icons/stars.svg index 7b588adeb6..c2bc4c9561 100644 --- a/webapp/src/svgs/icons/stars.svg +++ b/webapp/src/svgs/icons/stars.svg @@ -1,11 +1,5 @@ - - - - \ No newline at end of file + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/suggestion.svg b/webapp/src/svgs/icons/suggestion.svg new file mode 100644 index 0000000000..62eff43076 --- /dev/null +++ b/webapp/src/svgs/icons/suggestion.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/taskDetail.svg b/webapp/src/svgs/icons/taskDetail.svg new file mode 100644 index 0000000000..8e5ea36840 --- /dev/null +++ b/webapp/src/svgs/icons/taskDetail.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/taskinfo.svg b/webapp/src/svgs/icons/taskinfo.svg new file mode 100644 index 0000000000..7079e11867 --- /dev/null +++ b/webapp/src/svgs/icons/taskinfo.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/webapp/src/svgs/icons/translation-memory.svg b/webapp/src/svgs/icons/translation-memory.svg new file mode 100644 index 0000000000..fdde2bc0fe --- /dev/null +++ b/webapp/src/svgs/icons/translation-memory.svg @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/webapp/src/translationTools/useStateTranslation.ts b/webapp/src/translationTools/useStateTranslation.ts index d1a3cd7b34..b806313fdf 100644 --- a/webapp/src/translationTools/useStateTranslation.ts +++ b/webapp/src/translationTools/useStateTranslation.ts @@ -1,12 +1,10 @@ import { useTranslate } from '@tolgee/react'; +import { TranslationStateType } from 'tg.component/task/taskCreate/TranslationStateFilter'; import { exhaustiveMatchingGuard } from 'tg.fixtures/exhaustiveMatchingGuard'; -import { components } from 'tg.service/apiSchema.generated'; - -type State = components['schemas']['TranslationViewModel']['state']; export function useStateTranslation() { const { t } = useTranslate(); - return function (state: State) { + return function (state: TranslationStateType) { switch (state) { case 'UNTRANSLATED': return t('translation_state_untranslated'); @@ -20,8 +18,11 @@ export function useStateTranslation() { case 'DISABLED': return t('translation_state_disabled'); + case 'OUTDATED': + return t('translation_state_outdated'); + default: - exhaustiveMatchingGuard(state); + return exhaustiveMatchingGuard(state); } }; } diff --git a/webapp/src/translationTools/useTaskStateTranslation.ts b/webapp/src/translationTools/useTaskStateTranslation.ts new file mode 100644 index 0000000000..a0dfa00d92 --- /dev/null +++ b/webapp/src/translationTools/useTaskStateTranslation.ts @@ -0,0 +1,23 @@ +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; + +type TaskType = components['schemas']['TaskModel']['state']; + +export function useTaskStateTranslation() { + const { t } = useTranslate(); + + return (code: TaskType) => { + switch (code) { + case 'CLOSED': + return t('task_state_closed'); + case 'DONE': + return t('task_state_done'); + case 'IN_PROGRESS': + return t('task_state_in_progress'); + case 'NEW': + return t('task_state_new'); + default: + return code; + } + }; +} diff --git a/webapp/src/translationTools/useTaskTranslation.ts b/webapp/src/translationTools/useTaskTranslation.ts new file mode 100644 index 0000000000..c6c9343841 --- /dev/null +++ b/webapp/src/translationTools/useTaskTranslation.ts @@ -0,0 +1,19 @@ +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; + +type TaskType = components['schemas']['TaskModel']['type']; + +export function useTaskTypeTranslation() { + const { t } = useTranslate(); + + return (code: TaskType) => { + switch (code) { + case 'REVIEW': + return t('task_type_review'); + case 'TRANSLATE': + return t('task_type_translate'); + default: + return code; + } + }; +} diff --git a/webapp/src/views/administration/components/OptionsButton.tsx b/webapp/src/views/administration/components/OptionsButton.tsx index 2029bd41f8..878469a8dc 100644 --- a/webapp/src/views/administration/components/OptionsButton.tsx +++ b/webapp/src/views/administration/components/OptionsButton.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { MoreVert } from '@mui/icons-material'; +import { DotsVertical } from '@untitled-ui/icons-react'; import { IconButton, Menu, MenuItem } from '@mui/material'; import { useTranslate, T } from '@tolgee/react'; @@ -94,7 +94,7 @@ export function OptionsButton({ user }: Props) { onClick={(e) => setAnchor(e.target as HTMLElement)} data-cy="administration-user-menu" > - + {anchor && ( void; + search: string; +}; + +export const MyTasksBoard = ({ + showClosed, + filter, + onOpenDetail, + search, +}: Props) => { + const query = { + size: 20, + search, + sort: ['createdAt,desc'], + filterProject: filter.projects, + filterType: filter.types, + } satisfies QueryParameters; + + const newTasks = useMyBoardTask({ + query: { ...query, filterState: ['NEW'] }, + }); + + const inProgressTasks = useMyBoardTask({ + query: { ...query, filterState: ['IN_PROGRESS'] }, + }); + + const doneTasks = useMyBoardTask({ + query: { + ...query, + filterState: showClosed ? ['DONE', 'CLOSED'] : ['DONE'], + filterDoneMinClosedAt: filter.doneMinClosedAt, + }, + }); + + return ( + onOpenDetail(t as TaskWithProjectModel)} + doneTasks={doneTasks} + inProgressTasks={inProgressTasks} + newTasks={newTasks} + /> + ); +}; diff --git a/webapp/src/views/myTasks/MyTasksList.tsx b/webapp/src/views/myTasks/MyTasksList.tsx new file mode 100644 index 0000000000..4c52451993 --- /dev/null +++ b/webapp/src/views/myTasks/MyTasksList.tsx @@ -0,0 +1,82 @@ +import { ListProps, PaperProps, styled } from '@mui/material'; +import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; +import { TaskFilterType } from 'tg.component/task/taskFilter/TaskFilterPopover'; +import { TaskItem } from 'tg.component/task/TaskItem'; +import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; + +type TaskWithProjectModel = components['schemas']['TaskWithProjectModel']; + +const StyledSeparator = styled('div')` + grid-column: 1 / -1; + height: 1px; + background: ${({ theme }) => theme.palette.tokens.divider}; +`; + +type Props = { + showClosed: boolean; + filter: TaskFilterType; + onOpenDetail: (task: TaskWithProjectModel) => void; + search: string; +}; + +export const MyTasksList = ({ + showClosed, + filter, + search, + onOpenDetail, +}: Props) => { + const [page, setPage] = useUrlSearchState('page', { defaultVal: '0' }); + + const tasksLoadable = useApiQuery({ + url: '/v2/user-tasks', + method: 'get', + query: { + size: 20, + page: Number(page), + search, + sort: ['createdAt,desc'], + filterNotState: showClosed ? undefined : ['CLOSED'], + filterProject: filter.projects, + filterType: filter.types, + filterDoneMinClosedAt: filter.doneMinClosedAt, + }, + options: { + keepPreviousData: true, + }, + }); + + return ( + setPage(String(val))} + listComponentProps={ + { + sx: { + display: 'grid', + gridTemplateColumns: + '1fr minmax(15%, max-content) minmax(25%, max-content) minmax(10%, max-content) auto', + alignItems: 'center', + }, + } as ListProps + } + wrapperComponentProps={ + { + sx: { + border: 'none', + background: 'none', + }, + } as PaperProps + } + renderItem={(task) => ( + onOpenDetail(task as TaskWithProjectModel)} + project={task.project} + /> + )} + itemSeparator={() => } + /> + ); +}; diff --git a/webapp/src/views/myTasks/MyTasksView.tsx b/webapp/src/views/myTasks/MyTasksView.tsx new file mode 100644 index 0000000000..089bc737b5 --- /dev/null +++ b/webapp/src/views/myTasks/MyTasksView.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { HomeLine } from '@untitled-ui/icons-react'; +import { Dialog, useMediaQuery } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; + +import { BaseView } from 'tg.component/layout/BaseView'; +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { LINKS } from 'tg.constants/links'; +import { components } from 'tg.service/apiSchema.generated'; +import { TaskDetail } from 'tg.component/task/TaskDetail'; +import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; +import { TaskFilterType } from 'tg.component/task/taskFilter/TaskFilterPopover'; +import { TasksHeader } from 'tg.component/task/tasksHeader/TasksHeader'; +import { TaskView } from 'tg.component/task/tasksHeader/TasksHeaderBig'; + +import { MyTasksList } from './MyTasksList'; +import { MyTasksBoard } from './MyTasksBoard'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; + +type TaskWithProjectModel = components['schemas']['TaskWithProjectModel']; + +const DAY = 1000 * 60 * 60 * 24; + +export const MyTasksView = () => { + const { t } = useTranslate(); + const [detail, setDetail] = useState(); + const [minus30Days] = useState(Date.now() - DAY * 30); + const [view, setView] = useUrlSearchState('view', { + defaultVal: 'LIST', + }); + + const [search, setSearch] = useUrlSearchState('search', { defaultVal: '' }); + const [showClosed, setShowClosed] = useUrlSearchState('showClosed', { + defaultVal: 'false', + }); + + const [projects, setProjects] = useUrlSearchState('project', { + array: true, + }); + const [types, setTypes] = useUrlSearchState('type', { + array: true, + }); + + const filter: TaskFilterType = { + projects: projects?.map((p) => Number(p)), + types: types as any[], + doneMinClosedAt: showClosed === 'true' ? undefined : minus30Days, + }; + + const rightPanelWidth = useGlobalContext((c) => c.layout.rightPanelWidth); + + const isSmall = useMediaQuery( + `@media(max-width: ${rightPanelWidth + 1000}px)` + ); + + function setFilter(val: TaskFilterType) { + setProjects(val.projects?.map((p) => String(p))); + setTypes(val.types?.map((l) => String(l))); + } + + function handleDetailClose() { + setDetail(undefined); + } + + return ( + + , + ], + [t('my_tasks_title'), LINKS.MY_TASKS.build()], + ]} + > + setShowClosed(String(val))} + filter={filter} + onFilterChange={setFilter} + view={view as TaskView} + onViewChange={setView} + isSmall={isSmall} + /> + {view === 'LIST' && !isSmall ? ( + + ) : ( + + )} + + {detail !== undefined && ( + + + + )} + + ); +}; diff --git a/webapp/src/views/myTasks/useMyBoardTask.tsx b/webapp/src/views/myTasks/useMyBoardTask.tsx new file mode 100644 index 0000000000..74b2d86c2f --- /dev/null +++ b/webapp/src/views/myTasks/useMyBoardTask.tsx @@ -0,0 +1,46 @@ +import { components, operations } from 'tg.service/apiSchema.generated'; +import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; + +type QueryParameters = operations['getTasks_1']['parameters']['query']; +type TaskWithProjectModel = components['schemas']['TaskWithProjectModel']; + +type Props = { + query: QueryParameters; +}; + +export const useMyBoardTask = ({ query }: Props) => { + const result = useApiInfiniteQuery({ + url: '/v2/user-tasks', + method: 'get', + query, + options: { + keepPreviousData: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + const items: TaskWithProjectModel[] = []; + + result.data?.pages.forEach((data) => + data._embedded?.tasks?.forEach((t) => items.push(t)) + ); + + return { + ...result, + items, + }; +}; diff --git a/webapp/src/views/organizations/apps/slack/SlackApp.tsx b/webapp/src/views/organizations/apps/slack/SlackApp.tsx index 8187fec74a..6d1d57367b 100644 --- a/webapp/src/views/organizations/apps/slack/SlackApp.tsx +++ b/webapp/src/views/organizations/apps/slack/SlackApp.tsx @@ -74,7 +74,7 @@ export const SlackApp = () => { const getUrlMutation = useApiMutation({ url: '/v2/organizations/{organizationId}/slack/get-connect-url', method: 'get', - invalidatePrefix: '/v2/organizations/{organizationId}/', + invalidatePrefix: '/v2/organizations/{organizationId}', }); const workspaces = useApiQuery({ diff --git a/webapp/src/views/organizations/members/InvitationItem.tsx b/webapp/src/views/organizations/members/InvitationItem.tsx index b032896483..78449c50ca 100644 --- a/webapp/src/views/organizations/members/InvitationItem.tsx +++ b/webapp/src/views/organizations/members/InvitationItem.tsx @@ -1,6 +1,6 @@ import { T, useTranslate } from '@tolgee/react'; import { IconButton, styled, Tooltip } from '@mui/material'; -import { Link, Clear } from '@mui/icons-material'; +import { Link02, XClose } from '@untitled-ui/icons-react'; import { components } from 'tg.service/apiSchema.generated'; import { useApiMutation } from 'tg.service/http/useQueryApi'; @@ -90,7 +90,7 @@ export const InvitationItem: React.FC = ({ invitation }) => { size="small" onClick={handleGetLink} > - + @@ -100,7 +100,7 @@ export const InvitationItem: React.FC = ({ invitation }) => { size="small" onClick={handleCancel} > - + diff --git a/webapp/src/views/organizations/members/MemberItem.tsx b/webapp/src/views/organizations/members/MemberItem.tsx index 145429cd86..5e6b5d05f7 100644 --- a/webapp/src/views/organizations/members/MemberItem.tsx +++ b/webapp/src/views/organizations/members/MemberItem.tsx @@ -10,7 +10,7 @@ import { DialogContent, Link as MuiLink, } from '@mui/material'; -import { Clear, Info } from '@mui/icons-material'; +import { XClose, InfoCircle } from '@untitled-ui/icons-react'; import { useUser } from 'tg.globalContext/helpers'; import { Link } from 'react-router-dom'; @@ -49,7 +49,7 @@ const StyledItemActions = styled('div')` flex-wrap: wrap; `; -const StyledInfo = styled(Info)` +const StyledInfo = styled(InfoCircle)` opacity: 0.5; `; @@ -101,7 +101,7 @@ export const MemberItem: React.FC = ({ user, organizationId }) => { onClick={() => leaveOrganization(organizationId)} data-cy="organization-member-leave-button" > - + ) : ( diff --git a/webapp/src/views/organizations/members/RemoveUserButton.tsx b/webapp/src/views/organizations/members/RemoveUserButton.tsx index 9309ba0fed..ce099d6d91 100644 --- a/webapp/src/views/organizations/members/RemoveUserButton.tsx +++ b/webapp/src/views/organizations/members/RemoveUserButton.tsx @@ -1,5 +1,5 @@ import { IconButton, Tooltip } from '@mui/material'; -import { Clear } from '@mui/icons-material'; +import { XClose } from '@untitled-ui/icons-react'; import { T, useTranslate } from '@tolgee/react'; import { confirmation } from 'tg.hooks/confirmation'; @@ -52,7 +52,7 @@ export const RemoveUserButton = (props: { onClick={removeUser} size="small" > - + ); diff --git a/webapp/src/views/projects/BaseProjectView.tsx b/webapp/src/views/projects/BaseProjectView.tsx index d41d3d19c5..5f726afff8 100644 --- a/webapp/src/views/projects/BaseProjectView.tsx +++ b/webapp/src/views/projects/BaseProjectView.tsx @@ -42,7 +42,7 @@ export const BaseProjectView: React.FC = ({ {...otherProps} navigation={[...prefixNavigation, ...(navigation || [])]} navigationRight={ - + diff --git a/webapp/src/views/projects/DashboardProjectListItem.tsx b/webapp/src/views/projects/DashboardProjectListItem.tsx index ae1838bc71..5f5c4a2647 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -7,12 +7,13 @@ import { Typography, useMediaQuery, } from '@mui/material'; +import { Translate01 } from '@untitled-ui/icons-react'; import { T, useTranslate } from '@tolgee/react'; import { Link, useHistory } from 'react-router-dom'; + import { LINKS, PARAMS } from 'tg.constants/links'; import { components } from 'tg.service/apiSchema.generated'; import { TranslationStatesBar } from 'tg.views/projects/TranslationStatesBar'; -import { TranslationIcon } from 'tg.component/CustomIcons'; import { ProjectListItemMenu } from 'tg.views/projects/ProjectListItemMenu'; import { stopBubble } from 'tg.fixtures/eventHandler'; import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; @@ -186,7 +187,7 @@ const DashboardProjectListItem = (p: ProjectWithStatsModel) => { size="small" className="translationIconButton" > - + - + diff --git a/webapp/src/views/projects/ProjectPage.tsx b/webapp/src/views/projects/ProjectPage.tsx index e78d8cd8dd..4014dae9a1 100644 --- a/webapp/src/views/projects/ProjectPage.tsx +++ b/webapp/src/views/projects/ProjectPage.tsx @@ -7,11 +7,7 @@ import { useProject } from 'tg.hooks/useProject'; import { ProjectMenu } from './projectMenu/ProjectMenu'; const StyledContent = styled('div')` - flex-grow: 1; - display: flex; - flex-direction: column; - align-items: stretch; - position: relative; + display: grid; max-width: 100%; `; diff --git a/webapp/src/views/projects/ProjectRouter.tsx b/webapp/src/views/projects/ProjectRouter.tsx index 9b39a5ba72..b5956a38c7 100644 --- a/webapp/src/views/projects/ProjectRouter.tsx +++ b/webapp/src/views/projects/ProjectRouter.tsx @@ -19,6 +19,8 @@ import { WebsocketPreview } from './WebsocketPreview'; import { DeveloperView } from './developer/DeveloperView'; import { HideObserver } from 'tg.component/layout/TopBar/HideObserver'; import { ActivityDetailRedirect } from 'tg.component/security/ActivityDetailRedirect'; +import { ProjectTasksView } from './tasks/ProjectTasksView'; +import { TaskRedirect } from './TaskRedirect'; const IntegrateView = React.lazy(() => import('tg.views/projects/integrate/IntegrateView').then((r) => ({ @@ -49,6 +51,10 @@ export const ProjectRouter = () => { + + + + @@ -85,6 +91,10 @@ export const ProjectRouter = () => { + + + + {/* Preview section... */} diff --git a/webapp/src/views/projects/TaskRedirect.tsx b/webapp/src/views/projects/TaskRedirect.tsx new file mode 100644 index 0000000000..57bd887e45 --- /dev/null +++ b/webapp/src/views/projects/TaskRedirect.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { BoxLoading } from 'tg.component/common/BoxLoading'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { useProject } from 'tg.hooks/useProject'; +import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; + +type TaskModel = components['schemas']['TaskModel']; + +export const TaskRedirect = () => { + const project = useProject(); + const history = useHistory(); + const [task] = useUrlSearchState('task', { + defaultVal: undefined, + }); + + const getLinkToTask = (task: TaskModel) => { + const languages = new Set([project.baseLanguage!.tag, task.language.tag]); + + return ( + `${LINKS.PROJECT_TRANSLATIONS.build({ + [PARAMS.PROJECT_ID]: project.id, + })}?task=${task.number}&` + + Array.from(languages) + .map((l) => `languages=${l}`) + .join('&') + ); + }; + + const taskLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}', + method: 'get', + path: { projectId: project.id, taskNumber: Number(task) }, + }); + + useEffect(() => { + if (taskLoadable.data) { + history.replace(getLinkToTask(taskLoadable.data)); + } + }, [taskLoadable.data]); + + return ; +}; diff --git a/webapp/src/views/projects/dashboard/LanguageStats/LanguageMenu.tsx b/webapp/src/views/projects/dashboard/LanguageStats/LanguageMenu.tsx index ef6bc8b33b..6b091ddfe8 100644 --- a/webapp/src/views/projects/dashboard/LanguageStats/LanguageMenu.tsx +++ b/webapp/src/views/projects/dashboard/LanguageStats/LanguageMenu.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { IconButton, Menu, MenuItem } from '@mui/material'; -import { MoreVert } from '@mui/icons-material'; +import { DotsVertical } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { LINKS, PARAMS } from 'tg.constants/links'; @@ -63,7 +63,7 @@ export const LanguageMenu: React.FC = ({ language }) => { onClick={handleOpen} data-cy="project-dashboard-language-menu" > - + )} diff --git a/webapp/src/views/projects/dashboard/ProjectDescription.tsx b/webapp/src/views/projects/dashboard/ProjectDescription.tsx index 6e0564662d..93f549bfe5 100644 --- a/webapp/src/views/projects/dashboard/ProjectDescription.tsx +++ b/webapp/src/views/projects/dashboard/ProjectDescription.tsx @@ -1,4 +1,4 @@ -import { Edit } from '@mui/icons-material'; +import { Edit02 } from '@untitled-ui/icons-react'; import { Box, IconButton, Link as MuiLink, styled } from '@mui/material'; import ReactMarkdown from 'react-markdown'; import { Link } from 'react-router-dom'; @@ -42,11 +42,10 @@ export const ProjectDescription: React.FC = ({ description }) => { - + )} diff --git a/webapp/src/views/projects/dashboard/ProjectSettingsRight.tsx b/webapp/src/views/projects/dashboard/ProjectSettingsRight.tsx index 5067fb986a..673e8e6972 100644 --- a/webapp/src/views/projects/dashboard/ProjectSettingsRight.tsx +++ b/webapp/src/views/projects/dashboard/ProjectSettingsRight.tsx @@ -1,4 +1,4 @@ -import { Settings } from '@mui/icons-material'; +import { Settings01 } from '@untitled-ui/icons-react'; import { IconButton, Tooltip, styled } from '@mui/material'; import { useTranslate } from '@tolgee/react'; import { Link } from 'react-router-dom'; @@ -26,7 +26,7 @@ export const ProjectSettingsRight = ({ project }: Props) => { component={Link} to={LINKS.PROJECT_EDIT.build({ [PARAMS.PROJECT_ID]: project.id })} > - + diff --git a/webapp/src/views/projects/dashboard/ProjectTotals.tsx b/webapp/src/views/projects/dashboard/ProjectTotals.tsx index 96d1285368..9666153db6 100644 --- a/webapp/src/views/projects/dashboard/ProjectTotals.tsx +++ b/webapp/src/views/projects/dashboard/ProjectTotals.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState } from 'react'; import clsx from 'clsx'; import { useTranslate } from '@tolgee/react'; import { Box, Menu, MenuItem, styled } from '@mui/material'; -import { Edit } from '@mui/icons-material'; +import { Edit02 } from '@untitled-ui/icons-react'; import { useHistory } from 'react-router-dom'; import { components } from 'tg.service/apiSchema.generated'; @@ -217,7 +217,7 @@ export const ProjectTotals: React.FC<{ {canEditLanguages && ( - + )} @@ -306,7 +306,7 @@ export const ProjectTotals: React.FC<{ {membersEditable && ( - + )} diff --git a/webapp/src/views/projects/developer/CopyUrlItem.tsx b/webapp/src/views/projects/developer/CopyUrlItem.tsx index 879ad1b914..5b51c1ce44 100644 --- a/webapp/src/views/projects/developer/CopyUrlItem.tsx +++ b/webapp/src/views/projects/developer/CopyUrlItem.tsx @@ -6,7 +6,7 @@ import { } from '@mui/material'; import { T } from '@tolgee/react'; import copy from 'copy-to-clipboard'; -import { ContentCopy } from '@mui/icons-material'; +import { Copy06 } from '@untitled-ui/icons-react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; @@ -34,7 +34,7 @@ export const CopyUrlItem = ({ value, inputProps, maxWidth = 350 }: Props) => { messaging.success(); }} > - + } diff --git a/webapp/src/views/projects/developer/contentDelivery/CdAutoPublish.tsx b/webapp/src/views/projects/developer/contentDelivery/CdAutoPublish.tsx index c174aae12f..023e8afc91 100644 --- a/webapp/src/views/projects/developer/contentDelivery/CdAutoPublish.tsx +++ b/webapp/src/views/projects/developer/contentDelivery/CdAutoPublish.tsx @@ -1,7 +1,13 @@ import { Field } from 'formik'; import { useTranslate } from '@tolgee/react'; -import { Checkbox, FormControlLabel, Tooltip, styled } from '@mui/material'; -import { Help } from '@mui/icons-material'; +import { + Box, + Checkbox, + FormControlLabel, + Tooltip, + styled, +} from '@mui/material'; +import { HelpCircle } from '@untitled-ui/icons-react'; const StyledLabel = styled('div')` display: flex; @@ -9,8 +15,9 @@ const StyledLabel = styled('div')` align-items: center; `; -const StyledHelpIcon = styled(Help)` - font-size: 17px; +const StyledHelpIcon = styled(HelpCircle)` + height: 17px; + width: 17px; `; type Props = { @@ -30,7 +37,9 @@ export const CdAutoPublish: React.FC = ({ className }) => {
{t('export_translations_auto_publish_label')}
- + + +
} diff --git a/webapp/src/views/projects/developer/contentDelivery/CdList.tsx b/webapp/src/views/projects/developer/contentDelivery/CdList.tsx index 4a274e7bb2..990b3cbe47 100644 --- a/webapp/src/views/projects/developer/contentDelivery/CdList.tsx +++ b/webapp/src/views/projects/developer/contentDelivery/CdList.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { Box, Button, Link, Typography } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; @@ -89,7 +89,7 @@ export const CdList = () => { color="primary" onClick={() => setFormOpen(true)} disabled={!canAdd} - startIcon={} + startIcon={} data-cy="content-delivery-add-button" > {t('content_delivery_add_button')} diff --git a/webapp/src/views/projects/developer/contentDelivery/CdPruneBeforePublish.tsx b/webapp/src/views/projects/developer/contentDelivery/CdPruneBeforePublish.tsx index 165f256801..52ecf0f0a1 100644 --- a/webapp/src/views/projects/developer/contentDelivery/CdPruneBeforePublish.tsx +++ b/webapp/src/views/projects/developer/contentDelivery/CdPruneBeforePublish.tsx @@ -1,7 +1,13 @@ import { Field } from 'formik'; import { useTranslate } from '@tolgee/react'; -import { Checkbox, FormControlLabel, Tooltip, styled } from '@mui/material'; -import { Help } from '@mui/icons-material'; +import { + Box, + Checkbox, + FormControlLabel, + Tooltip, + styled, +} from '@mui/material'; +import { HelpCircle } from '@untitled-ui/icons-react'; import { FC } from 'react'; const StyledLabel = styled('div')` @@ -10,8 +16,9 @@ const StyledLabel = styled('div')` align-items: center; `; -const StyledHelpIcon = styled(Help)` - font-size: 17px; +const StyledHelpIcon = styled(HelpCircle)` + width: 17px; + height: 17px; `; type Props = { @@ -39,7 +46,9 @@ export const CdPruneBeforePublish: FC = ({ className }) => { 'content_delivery_translations_prune_before_publish_hint' )} > - + + + } diff --git a/webapp/src/views/projects/developer/storage/StorageList.tsx b/webapp/src/views/projects/developer/storage/StorageList.tsx index aa7c8a6eaf..ab7667b871 100644 --- a/webapp/src/views/projects/developer/storage/StorageList.tsx +++ b/webapp/src/views/projects/developer/storage/StorageList.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { T, useTranslate } from '@tolgee/react'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { Box, Button, Typography } from '@mui/material'; import { BoxLoading } from 'tg.component/common/BoxLoading'; @@ -75,7 +75,7 @@ export const StorageList = () => { variant="contained" color="primary" onClick={() => setFormOpen(true)} - startIcon={} + startIcon={} data-cy="storage-add-item-button" disabled={!canManage || !isEnabled} > diff --git a/webapp/src/views/projects/developer/webhook/WebhookItem.tsx b/webapp/src/views/projects/developer/webhook/WebhookItem.tsx index 8a2c59cc1a..c70c7cd30b 100644 --- a/webapp/src/views/projects/developer/webhook/WebhookItem.tsx +++ b/webapp/src/views/projects/developer/webhook/WebhookItem.tsx @@ -9,8 +9,9 @@ import { DialogTitle, styled, Tooltip, + useTheme, } from '@mui/material'; -import { ErrorOutlineOutlined } from '@mui/icons-material'; +import { AlertCircle } from '@untitled-ui/icons-react'; import { components } from 'tg.service/apiSchema.generated'; import LoadingButton from 'tg.component/common/form/LoadingButton'; @@ -50,6 +51,7 @@ type Props = { export const WebhookItem = ({ data }: Props) => { const project = useProject(); const messaging = useMessage(); + const theme = useTheme(); const { t } = useTranslate(); const formatDate = useDateFormatter(); const [formOpen, setFormOpen] = useState(false); @@ -93,7 +95,9 @@ export const WebhookItem = ({ data }: Props) => { )} {data.firstFailed && ( - + + + )} diff --git a/webapp/src/views/projects/developer/webhook/WebhookList.tsx b/webapp/src/views/projects/developer/webhook/WebhookList.tsx index 8fc6e78813..c5d834d79e 100644 --- a/webapp/src/views/projects/developer/webhook/WebhookList.tsx +++ b/webapp/src/views/projects/developer/webhook/WebhookList.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { T, useTranslate } from '@tolgee/react'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { Box, Button, Typography } from '@mui/material'; import { BoxLoading } from 'tg.component/common/BoxLoading'; @@ -63,7 +63,7 @@ export const WebhookList = () => { color="primary" onClick={() => setFormOpen(true)} disabled={!canAdd || !isEnabled} - startIcon={} + startIcon={} data-cy="webhooks-add-item-button" > {t('webhooks_add_button')} diff --git a/webapp/src/views/projects/export/components/LanguageSelector.tsx b/webapp/src/views/projects/export/components/LanguageSelector.tsx index 83b7c1d855..6f750b3482 100644 --- a/webapp/src/views/projects/export/components/LanguageSelector.tsx +++ b/webapp/src/views/projects/export/components/LanguageSelector.tsx @@ -17,7 +17,7 @@ type LanguageModel = components['schemas']['LanguageModel']; type Props = { languages: LanguageModel[] | undefined; - className: string; + className?: string; }; export const LanguageSelector: React.FC = ({ languages, className }) => { diff --git a/webapp/src/views/projects/export/components/NestedSelector.tsx b/webapp/src/views/projects/export/components/NestedSelector.tsx deleted file mode 100644 index e59c778a85..0000000000 --- a/webapp/src/views/projects/export/components/NestedSelector.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Field } from 'formik'; -import { useTranslate } from '@tolgee/react'; -import { Checkbox, FormControlLabel, Tooltip, styled } from '@mui/material'; -import { Help } from '@mui/icons-material'; - -const StyledLabel = styled('div')` - display: flex; - gap: 5px; - align-items: center; -`; - -const StyledHelpIcon = styled(Help)` - font-size: 17px; -`; - -type Props = { - className?: string; -}; - -export const NestedSelector: React.FC = ({ className }) => { - const { t } = useTranslate(); - - return ( - - {({ field }) => { - return ( - -
{t('export_translations_nested_label')}
- - - - - } - control={ - <> - - - } - /> - ); - }} -
- ); -}; diff --git a/webapp/src/views/projects/export/components/SupportArraysSelector.tsx b/webapp/src/views/projects/export/components/SupportArraysSelector.tsx index 301a4cc219..1769699ad1 100644 --- a/webapp/src/views/projects/export/components/SupportArraysSelector.tsx +++ b/webapp/src/views/projects/export/components/SupportArraysSelector.tsx @@ -1,7 +1,13 @@ import { Field } from 'formik'; import { useTranslate } from '@tolgee/react'; -import { Checkbox, FormControlLabel, Tooltip, styled } from '@mui/material'; -import { Help } from '@mui/icons-material'; +import { + Box, + Checkbox, + FormControlLabel, + Tooltip, + styled, +} from '@mui/material'; +import { HelpCircle } from '@untitled-ui/icons-react'; const StyledLabel = styled('div')` display: flex; @@ -9,8 +15,9 @@ const StyledLabel = styled('div')` align-items: center; `; -const StyledHelpIcon = styled(Help)` - font-size: 17px; +const StyledHelpIcon = styled(HelpCircle)` + width: 17px; + height: 17px; `; type Props = { @@ -30,7 +37,9 @@ export const SupportArraysSelector: React.FC = ({ className }) => {
{t('export_translations_support_arrays_label')}
- + + +
} diff --git a/webapp/src/views/projects/import/component/ImportAlertError.tsx b/webapp/src/views/projects/import/component/ImportAlertError.tsx index be5bc03015..65ade09235 100644 --- a/webapp/src/views/projects/import/component/ImportAlertError.tsx +++ b/webapp/src/views/projects/import/component/ImportAlertError.tsx @@ -12,7 +12,7 @@ import { Collapse, IconButton, } from '@mui/material'; -import CloseIcon from '@mui/icons-material/Close'; +import { XClose } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { components } from 'tg.service/apiSchema.generated'; @@ -73,7 +73,7 @@ export const ImportAlertError: FunctionComponent<{ setCollapsed(true); }} > - + } diff --git a/webapp/src/views/projects/import/component/ImportConflictResolutionDialog.tsx b/webapp/src/views/projects/import/component/ImportConflictResolutionDialog.tsx index 4ef8bc7abe..3806d9275a 100644 --- a/webapp/src/views/projects/import/component/ImportConflictResolutionDialog.tsx +++ b/webapp/src/views/projects/import/component/ImportConflictResolutionDialog.tsx @@ -6,7 +6,7 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { styled } from '@mui/material/styles'; import { TransitionProps } from '@mui/material/transitions'; -import CloseIcon from '@mui/icons-material/Close'; +import { XClose } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { useTheme } from '@mui/material'; @@ -51,7 +51,7 @@ export const ImportConflictResolutionDialog: FunctionComponent<{ data-cy="import-resolution-dialog-close-button" size="large" > - + diff --git a/webapp/src/views/projects/import/component/ImportConflictTranslation.tsx b/webapp/src/views/projects/import/component/ImportConflictTranslation.tsx index def64ee57d..e5f751da7f 100644 --- a/webapp/src/views/projects/import/component/ImportConflictTranslation.tsx +++ b/webapp/src/views/projects/import/component/ImportConflictTranslation.tsx @@ -1,9 +1,7 @@ import React, { FunctionComponent, LegacyRef, useEffect } from 'react'; import { Box, BoxProps, IconButton, styled } from '@mui/material'; import { green } from '@mui/material/colors'; -import { KeyboardArrowUp } from '@mui/icons-material'; -import CheckIcon from '@mui/icons-material/Check'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import { ChevronUp, ChevronDown, Check } from '@untitled-ui/icons-react'; import clsx from 'clsx'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; import { TranslationVisual } from 'tg.views/projects/translations/translationVisual/TranslationVisual'; @@ -117,7 +115,7 @@ export const ImportConflictTranslation: React.FC = (props) => { p={1} data-cy="import-resolution-dialog-translation-check" > - + )} = (props) => { }} size="large" > - {!props.expanded ? : } + {!props.expanded ? : } )} diff --git a/webapp/src/views/projects/import/component/ImportConflictsDataHeader.tsx b/webapp/src/views/projects/import/component/ImportConflictsDataHeader.tsx index 2ee1150b67..3792ee4063 100644 --- a/webapp/src/views/projects/import/component/ImportConflictsDataHeader.tsx +++ b/webapp/src/views/projects/import/component/ImportConflictsDataHeader.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from 'react'; +import { FunctionComponent } from 'react'; import { Box, Button, @@ -7,7 +7,7 @@ import { useMediaQuery, useTheme, } from '@mui/material'; -import { DoneAll } from '@mui/icons-material'; +import { CheckDone01 } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { useProject } from 'tg.hooks/useProject'; @@ -62,7 +62,7 @@ export const ImportConflictsDataHeader: FunctionComponent<{ - + diff --git a/webapp/src/views/projects/import/component/ImportSettingsPanel.tsx b/webapp/src/views/projects/import/component/ImportSettingsPanel.tsx index e4b04335f6..748f1e76fb 100644 --- a/webapp/src/views/projects/import/component/ImportSettingsPanel.tsx +++ b/webapp/src/views/projects/import/component/ImportSettingsPanel.tsx @@ -5,7 +5,7 @@ import { components } from 'tg.service/apiSchema.generated'; import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; import { useProject } from 'tg.hooks/useProject'; import { LoadingCheckboxWithSkeleton } from 'tg.component/common/form/LoadingCheckboxWithSkeleton'; -import { HelpOutline } from '@mui/icons-material'; +import { HelpCircle } from '@untitled-ui/icons-react'; import { DOC_LINKS } from '../../../../docLinks'; type ImportSettingRequest = components['schemas']['ImportSettingsRequest']; @@ -111,7 +111,7 @@ export const ImportSettingsPanel: FC = (props) => { {...additionalCheckboxProps} customHelpIcon={ - + } /> @@ -127,7 +127,9 @@ export const ImportSettingsPanel: FC = (props) => { checked={state?.overrideKeyDescriptions} customHelpIcon={ - + + + } {...additionalCheckboxProps} @@ -156,6 +158,7 @@ const StyledLink = styled('a')` color: ${({ theme }) => theme.palette.tokens.icon.primary}; .icon { - font-size: 16px; + width: 18px; + height: 18px; } `; diff --git a/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx b/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx index 4cf9f2a794..7c8e35d865 100644 --- a/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx +++ b/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx @@ -7,7 +7,7 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { useTheme } from '@mui/material/styles'; import { TransitionProps } from '@mui/material/transitions'; -import CloseIcon from '@mui/icons-material/Close'; +import { XClose } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import SearchField from 'tg.component/common/form/fields/SearchField'; @@ -87,7 +87,7 @@ export const ImportTranslationsDialog: FunctionComponent<{ aria-label="close" size="large" > - + diff --git a/webapp/src/views/projects/import/component/LanguageSelector.tsx b/webapp/src/views/projects/import/component/LanguageSelector.tsx index 461e2dd4f6..ff9a89fb85 100644 --- a/webapp/src/views/projects/import/component/LanguageSelector.tsx +++ b/webapp/src/views/projects/import/component/LanguageSelector.tsx @@ -8,7 +8,7 @@ import { Select, styled, } from '@mui/material'; -import { Add, Clear } from '@mui/icons-material'; +import { Plus, XClose } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { useQueryClient } from 'react-query'; @@ -35,7 +35,7 @@ const StyledItemContent = styled('div')` align-items: center; `; -const StyledAddIcon = styled(Add)` +const StyledPlusIcon = styled(Plus)` margin-right: ${({ theme }) => theme.spacing(1)}; margin-left: -2px; `; @@ -77,7 +77,7 @@ export const LanguageSelector: React.FC<{ items.push( - + ); @@ -103,7 +103,7 @@ export const LanguageSelector: React.FC<{ size="small" data-cy="import-row-language-select-clear-button" > - + ) : ( diff --git a/webapp/src/views/projects/integrate/component/ApiKeySelector.tsx b/webapp/src/views/projects/integrate/component/ApiKeySelector.tsx index fb0acbee54..9ff285baa0 100644 --- a/webapp/src/views/projects/integrate/component/ApiKeySelector.tsx +++ b/webapp/src/views/projects/integrate/component/ApiKeySelector.tsx @@ -1,7 +1,7 @@ -import { default as React, FC, useState } from 'react'; +import { FC, useState } from 'react'; import { Box, FormControl, MenuItem, Select, styled } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; -import { Add } from '@mui/icons-material'; +import { Plus } from '@untitled-ui/icons-react'; import { components } from 'tg.service/apiSchema.generated'; import { BoxLoading } from 'tg.component/common/BoxLoading'; @@ -27,8 +27,8 @@ const StyledScopes = styled('div')` font-style: italic; `; -const StyledAddIcon = styled(Add)` - marginright: ${({ theme }) => theme.spacing(1)}; +const StyledAddIcon = styled(Plus)` + margin-left: ${({ theme }) => theme.spacing(0.5)}; `; export const ApiKeySelector: FC<{ @@ -92,7 +92,7 @@ export const ApiKeySelector: FC<{ > - + diff --git a/webapp/src/views/projects/integrate/guides.tsx b/webapp/src/views/projects/integrate/guides.tsx index b04037452c..6b1a4b033a 100644 --- a/webapp/src/views/projects/integrate/guides.tsx +++ b/webapp/src/views/projects/integrate/guides.tsx @@ -1,6 +1,6 @@ import { default as React } from 'react'; import { Guide } from 'tg.views/projects/integrate/types'; -import { Code, Settings, Terminal } from '@mui/icons-material'; +import { Code01, Settings01, Terminal } from '@untitled-ui/icons-react'; const getTechnologyImgComponent = (imgName: string) => { return function TechnologyImage(props) { @@ -37,7 +37,7 @@ export const guides = [ }, { name: 'Web', - icon: Code, + icon: Code01, guide: React.lazy(() => import('./guides/Web.mdx')), }, { @@ -52,7 +52,7 @@ export const guides = [ }, { name: 'Rest', - icon: Settings, + icon: Settings01, guide: React.lazy(() => import('./guides/Rest.mdx')), }, { diff --git a/webapp/src/views/projects/languages/AiCustomization/AiExampleBanner.tsx b/webapp/src/views/projects/languages/AiCustomization/AiExampleBanner.tsx index b2a026894e..58da13f2c0 100644 --- a/webapp/src/views/projects/languages/AiCustomization/AiExampleBanner.tsx +++ b/webapp/src/views/projects/languages/AiCustomization/AiExampleBanner.tsx @@ -1,5 +1,5 @@ import { Box, styled } from '@mui/material'; -import { StarsIcon } from 'tg.component/CustomIcons'; +import { Stars } from 'tg.component/CustomIcons'; const StyledWrapper = styled(Box)` border-radius: 4px; @@ -33,7 +33,7 @@ export const AiExampleBanner = ({ label, items, action }: Props) => { - +
{label}
diff --git a/webapp/src/views/projects/languages/AiCustomization/AiLanguagesTableRow.tsx b/webapp/src/views/projects/languages/AiCustomization/AiLanguagesTableRow.tsx index 3a18dc6f8e..1dbe5ec9cc 100644 --- a/webapp/src/views/projects/languages/AiCustomization/AiLanguagesTableRow.tsx +++ b/webapp/src/views/projects/languages/AiCustomization/AiLanguagesTableRow.tsx @@ -3,7 +3,7 @@ import { components } from 'tg.service/apiSchema.generated'; import { TABLE_FIRST_CELL } from '../tableStyles'; import { LanguageItem } from '../LanguageItem'; import { IconButton, styled } from '@mui/material'; -import { Add, Edit } from '@mui/icons-material'; +import { Plus, Edit02 } from '@untitled-ui/icons-react'; import { AiLanguageDescriptionDialog } from './AiLanguageDescriptionDialog'; type LanguageModel = components['schemas']['LanguageModel']; @@ -46,7 +46,11 @@ export const AiLanguagesTableRow = ({ language, description }: Props) => { data-cy="ai-languages-description-edit" data-cy-language={language.tag} > - {description ? : } + {description ? ( + + ) : ( + + )} {dialogOpen && ( diff --git a/webapp/src/views/projects/languages/AiCustomization/AiProjectDescription.tsx b/webapp/src/views/projects/languages/AiCustomization/AiProjectDescription.tsx index 22a56e872c..084841e2de 100644 --- a/webapp/src/views/projects/languages/AiCustomization/AiProjectDescription.tsx +++ b/webapp/src/views/projects/languages/AiCustomization/AiProjectDescription.tsx @@ -1,9 +1,9 @@ import { useTranslate } from '@tolgee/react'; import { Box, Button, IconButton, styled } from '@mui/material'; -import { Add, Edit } from '@mui/icons-material'; +import { Plus, Edit02 } from '@untitled-ui/icons-react'; import { useState } from 'react'; import { AiProjectDescriptionDialog } from './AiProjectDescriptionDialog'; -import { StarsIcon } from 'tg.component/CustomIcons'; +import { Stars } from 'tg.component/CustomIcons'; import clsx from 'clsx'; const EXAMPLE = 'App for teaching children about the world.'; @@ -53,7 +53,7 @@ export const AiProjectDescription = ({ description }: Props) => { onClick={() => setDialogOpen(true)} data-cy="ai-customization-project-description-edit" > - +
@@ -61,7 +61,7 @@ export const AiProjectDescription = ({ description }: Props) => { <> - + {t('project_ai_prompt_example_label')} @@ -72,7 +72,7 @@ export const AiProjectDescription = ({ description }: Props) => { - - - + {displayTaskControls ? ( + <> + + onSave?.({})} + color="primary" + variant="contained" + loading={isEditLoading} + data-cy="translations-cell-save-button" + > + + + setOpen(true)} + > + + + + + + onSave?.({ preventTaskResolution: true }) + )} + > + + + + + ) : ( + onSave?.({})} + color="primary" + size="small" + variant="contained" + loading={isEditLoading} + data-cy="translations-cell-save-button" + > + + + )} ); }; diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx index b1bc861cc8..573d113f82 100644 --- a/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { T, useTranslate } from '@tolgee/react'; +import { useTranslate } from '@tolgee/react'; import { Box, styled } from '@mui/material'; -import { Code, ContentCopy } from '@mui/icons-material'; +import { Code01, ClipboardCheck, Copy06 } from '@untitled-ui/icons-react'; import { components } from 'tg.service/apiSchema.generated'; import { StateInType } from 'tg.constants/translationStates'; @@ -12,6 +12,7 @@ import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { useProject } from 'tg.hooks/useProject'; type State = components['schemas']['TranslationViewModel']['state']; +type TaskModel = components['schemas']['KeyTaskViewModel']; const StyledContainer = styled(Box)` display: flex; @@ -34,6 +35,8 @@ type ControlsProps = { onStateChange?: (state: StateInType) => void; onModeToggle?: () => void; controlsProps?: React.ComponentProps; + tasks?: TaskModel[]; + onTaskStateChange?: (done: boolean) => void; }; export const ControlsEditorSmall: React.FC = ({ @@ -45,6 +48,8 @@ export const ControlsEditorSmall: React.FC = ({ onModeToggle, onStateChange, controlsProps, + tasks, + onTaskStateChange, }) => { const project = useProject(); const { t } = useTranslate(); @@ -58,6 +63,12 @@ export const ControlsEditorSmall: React.FC = ({ !isBaseLanguage && satisfiesLanguageAccess('translations.view', baseLanguage?.id); + const task = tasks?.[0]; + + const prefilteredTask = useTranslationsSelector((c) => c.prefilter?.task); + const displayTaskButton = + task && task.number === prefilteredTask && task.userAssigned && !task.done; + const displayEditorMode = project.icuPlaceholders; return ( @@ -80,7 +91,7 @@ export const ControlsEditorSmall: React.FC = ({ : t('translations_editor_switch_hide_code') } > - + )} @@ -92,9 +103,20 @@ export const ControlsEditorSmall: React.FC = ({ }} color="default" data-cy="translations-cell-insert-base-button" - tooltip={} + tooltip={t('translations_cell_insert_base')} + > + + + )} + + {displayTaskButton && onTaskStateChange && ( + onTaskStateChange(!task?.done)} + color={task?.done ? 'secondary' : 'primary'} > - + )} diff --git a/webapp/src/views/projects/translations/cell/ControlsKey.tsx b/webapp/src/views/projects/translations/cell/ControlsKey.tsx index 26e6fc19ef..1c749c0a1a 100644 --- a/webapp/src/views/projects/translations/cell/ControlsKey.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsKey.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { T } from '@tolgee/react'; -import { CameraAlt, Edit } from '@mui/icons-material'; -import { styled } from '@mui/material'; +import { Camera01, Edit02 } from '@untitled-ui/icons-react'; +import { styled, useTheme } from '@mui/material'; import { CELL_SHOW_ON_HOVER } from './styles'; import { ControlsButton } from './ControlsButton'; @@ -31,6 +31,7 @@ export const ControlsKey: React.FC = ({ }) => { const { satisfiesPermission } = useProjectPermissions(); const canViewScreenshots = satisfiesPermission('screenshots.view'); + const theme = useTheme(); // right section const displayEdit = editEnabled && onEdit; @@ -45,7 +46,7 @@ export const ControlsKey: React.FC = ({ className={CELL_SHOW_ON_HOVER} tooltip={} > - + )} {displayScreenshots && ( @@ -60,9 +61,8 @@ export const ControlsKey: React.FC = ({ : CELL_SHOW_ON_HOVER } > - )} diff --git a/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx b/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx index 5da17c2198..5d82dab35e 100644 --- a/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx @@ -1,7 +1,12 @@ import React from 'react'; import clsx from 'clsx'; import { Badge, Box, styled } from '@mui/material'; -import { Check, Comment, Edit } from '@mui/icons-material'; +import { + Check, + MessageTextSquare02, + Edit02, + ClipboardCheck, +} from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; import { StateInType } from 'tg.constants/translationStates'; @@ -9,8 +14,10 @@ import { components } from 'tg.service/apiSchema.generated'; import { ControlsButton } from './ControlsButton'; import { StateTransitionButtons } from './StateTransitionButtons'; import { CELL_HIGHLIGHT_ON_HOVER, CELL_SHOW_ON_HOVER } from './styles'; +import { useTranslationsSelector } from '../context/TranslationsContext'; type State = components['schemas']['TranslationViewModel']['state']; +type TaskModel = components['schemas']['KeyTaskViewModel']; const StyledControlsWrapper = styled(Box)` display: grid; @@ -48,7 +55,8 @@ const StyledBadge = styled(Badge)` const StyledCheckIcon = styled(Check)` color: ${({ theme }) => theme.palette.emphasis[100]}; - font-size: 14px; + width: 14px !important; + height: 14px !important; margin: -5px; `; @@ -60,6 +68,8 @@ type ControlsProps = { onStateChange?: (state: StateInType) => void; onComments?: () => void; commentsCount: number | undefined; + tasks: TaskModel[] | undefined; + onTaskStateChange: (done: boolean) => void; unresolvedCommentCount: number | undefined; // render last focusable button lastFocusable: boolean; @@ -75,6 +85,8 @@ export const ControlsTranslation: React.FC = ({ onEdit, onStateChange, onComments, + tasks, + onTaskStateChange, commentsCount, unresolvedCommentCount, lastFocusable, @@ -88,6 +100,10 @@ export const ControlsTranslation: React.FC = ({ const commentsPresent = Boolean(commentsCount); const displayComments = onComments || commentsPresent; const onlyResolved = commentsPresent && !unresolvedCommentCount; + const prefilteredTask = useTranslationsSelector((c) => c.prefilter?.task); + const task = tasks?.[0]; + const displayTaskButton = + task && task.number === prefilteredTask && task.userAssigned; if (displayTransitionButtons) { spots.push('state'); @@ -98,10 +114,14 @@ export const ControlsTranslation: React.FC = ({ if (displayComments) { spots.push('comments'); } + if (displayTaskButton) { + spots.push('task'); + } const inDomTransitionButtons = displayTransitionButtons && active; const inDomEdit = displayEdit && active; const inDomComments = displayComments || active || lastFocusable; + const inDomTask = displayTaskButton; const gridTemplateAreas = `'${spots.join(' ')}'`; const gridTemplateColumns = spots @@ -133,7 +153,7 @@ export const ControlsTranslation: React.FC = ({ className={CELL_SHOW_ON_HOVER} tooltip={} > - + )} {inDomComments && ( @@ -149,12 +169,12 @@ export const ControlsTranslation: React.FC = ({ > {onlyResolved ? ( } + badgeContent={} classes={{ badge: 'resolved', }} > - + ) : ( = ({ color="primary" classes={{ badge: 'unresolved' }} > - + )} )} + {inDomTask && ( + onTaskStateChange(!task?.done)} + data-cy="translations-cell-task-button" + color={ + task?.userAssigned + ? task?.done + ? 'secondary' + : 'primary' + : undefined + } + > + + + )} ); }; diff --git a/webapp/src/views/projects/translations/cell/StateIcon.tsx b/webapp/src/views/projects/translations/cell/StateIcon.tsx index e7dac86adf..5b78097192 100644 --- a/webapp/src/views/projects/translations/cell/StateIcon.tsx +++ b/webapp/src/views/projects/translations/cell/StateIcon.tsx @@ -1,17 +1,18 @@ -import { CheckCircleOutlined, CheckCircle } from '@mui/icons-material'; +import { CheckCircleBroken } from '@untitled-ui/icons-react'; +import { CheckCircleDash } from 'tg.component/CustomIcons'; import { components } from 'tg.service/apiSchema.generated'; type State = components['schemas']['TranslationViewModel']['state']; -type StateButtonProps = React.ComponentProps & { +type StateButtonProps = React.ComponentProps & { state: State | undefined; }; export const StateIcon = ({ state, ...props }: StateButtonProps) => { switch (state) { case 'REVIEWED': - return ; + return ; default: - return ; + return ; } }; diff --git a/webapp/src/views/projects/translations/cell/StateTransitionButtons.tsx b/webapp/src/views/projects/translations/cell/StateTransitionButtons.tsx index bd93b099d3..feb4055cdf 100644 --- a/webapp/src/views/projects/translations/cell/StateTransitionButtons.tsx +++ b/webapp/src/views/projects/translations/cell/StateTransitionButtons.tsx @@ -1,4 +1,4 @@ -import { T } from '@tolgee/react'; +import { useTranslate } from '@tolgee/react'; import { StateInType, @@ -23,6 +23,7 @@ export const StateTransitionButtons: React.FC = ({ className, }) => { const translateState = useStateTranslation(); + const { t } = useTranslate(); const nextState = state && TRANSLATION_STATES[state]?.next; @@ -33,14 +34,9 @@ export const StateTransitionButtons: React.FC = ({ data-cy="translation-state-button" onClick={() => onStateChange?.(nextState)} className={className} - tooltip={ - <> - - - } + tooltip={t('translation_state_change', { + newState: translateState(nextState), + })} > diff --git a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx index a671e14a78..10d111d4d3 100644 --- a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx +++ b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx @@ -1,33 +1,47 @@ -import { styled } from '@mui/material'; -import { Clear, FlagCircle } from '@mui/icons-material'; +import { Box, Dialog, styled, useTheme } from '@mui/material'; +import { XClose, Flag02, ClipboardCheck } from '@untitled-ui/icons-react'; import { useTranslate } from '@tolgee/react'; import { components } from 'tg.service/apiSchema.generated'; import { useApiMutation } from 'tg.service/http/useQueryApi'; import { useProject } from 'tg.hooks/useProject'; import { AutoTranslationIcon } from 'tg.component/AutoTranslationIcon'; -import { TranslationFlagIcon } from 'tg.component/TranslationFlagIcon'; -import { useTranslationsActions } from '../context/TranslationsContext'; +import { + StyledImgWrapper, + TranslationFlagIcon, +} from 'tg.component/TranslationFlagIcon'; +import { + useTranslationsActions, + useTranslationsSelector, +} from '../context/TranslationsContext'; +import { useState } from 'react'; +import { TaskDetail } from 'tg.component/task/TaskDetail'; +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { TaskTooltip } from 'tg.component/task/TaskTooltip'; +import { Link } from 'react-router-dom'; +import { getTaskRedirect } from 'tg.component/task/utils'; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; +type TaskModel = components['schemas']['TaskModel']; const StyledWrapper = styled('div')` display: flex; gap: 2px; `; -const StyledClearButton = styled(Clear)` +const StyledClearButton = styled(XClose)` padding-left: 2px; - font-size: 18px; + width: 18px; + height: 18px; display: none; `; -const ActiveFlagCircle = styled(FlagCircle)` +const ActiveFlagCircle = styled(Flag02)` color: ${({ theme }) => theme.palette.primary.main}; `; -const StyledContainer = styled('div')` +const StyledContainer = styled(Box)` display: inline-flex; flex-grow: 0; align-items: center; @@ -57,11 +71,17 @@ export const TranslationFlags: React.FC = ({ lang, className, }) => { + const theme = useTheme(); const project = useProject(); const { t } = useTranslate(); const translation = keyData.translations[lang]; + const task = keyData.tasks?.find((t) => t.languageTag === lang); + const prefilteredTask = useTranslationsSelector((c) => c.prefilter?.task); + const displayTaskFlag = + task && (task.number !== prefilteredTask || !task.userAssigned); const { updateTranslation } = useTranslationsActions(); + const [taskDetailData, setTaskDetailData] = useState(); const clearAutoTranslatedState = useApiMutation({ url: '/v2/projects/{projectId}/translations/{translationId}/dismiss-auto-translated-state', @@ -111,9 +131,29 @@ export const TranslationFlags: React.FC = ({ }); }; - if (translation?.auto || translation?.outdated) { + if (translation?.auto || translation?.outdated || displayTaskFlag) { return ( + {displayTaskFlag && ( + + + + + + + + )} {translation.auto && ( @@ -139,6 +179,20 @@ export const TranslationFlags: React.FC = ({ /> )} + {taskDetailData && task && ( + setTaskDetailData(undefined)} + maxWidth="xl" + onClick={stopAndPrevent()} + > + setTaskDetailData(undefined)} + projectId={project.id} + /> + + )} ); } else { diff --git a/webapp/src/views/projects/translations/context/TranslationsContext.ts b/webapp/src/views/projects/translations/context/TranslationsContext.ts index 43cba4b86f..d19dd1a284 100644 --- a/webapp/src/views/projects/translations/context/TranslationsContext.ts +++ b/webapp/src/views/projects/translations/context/TranslationsContext.ts @@ -20,6 +20,7 @@ import { KeyElement, KeyUpdateData, RemoveTag, + SetTaskTranslationState, SetTranslationState, UpdateTranslation, ViewMode, @@ -33,6 +34,7 @@ import { useSelectionService } from './services/useSelectionService'; import { useStateService } from './services/useStateService'; import { useWebsocketService } from './services/useWebsocketService'; import { PrefilterType } from '../prefilters/usePrefilter'; +import { useTaskService } from './services/useTaskService'; type Props = { projectId: number; @@ -98,9 +100,16 @@ export const [ const viewRefs = useRefsService(); + const taskService = useTaskService({ translations: translationService }); + + const stateService = useStateService({ + translations: translationService, + taskService, + }); const editService = useEditService({ translations: translationService, viewRefs, + taskService, }); const tagsService = useTagsService({ @@ -111,8 +120,6 @@ export const [ translations: translationService, }); - const stateService = useStateService({ translations: translationService }); - const handleTranslationsReset = () => { editService.clearPosition(); selectionService.clear(); @@ -202,6 +209,9 @@ export const [ setTranslationState(state: SetTranslationState) { return stateService.changeState(state); }, + setTaskState(state: SetTaskTranslationState) { + return taskService.setTaskTranslationState(state); + }, addTag(tag: AddTag) { return tagsService.addTag(tag); }, diff --git a/webapp/src/views/projects/translations/context/services/useEditService.tsx b/webapp/src/views/projects/translations/context/services/useEditService.tsx index ca3b171bc5..61ef9880b0 100644 --- a/webapp/src/views/projects/translations/context/services/useEditService.tsx +++ b/webapp/src/views/projects/translations/context/services/useEditService.tsx @@ -32,6 +32,7 @@ import { SetEdit, } from '../types'; import { getPluralVariants } from '@tginternal/editor'; +import { useTaskService } from './useTaskService'; /** * Kinda hacky way how to update react-list size cache, when editor gets open @@ -61,6 +62,7 @@ type KeyWithTranslationsModelType = type Props = { translations: ReturnType; viewRefs: ReturnType; + taskService: ReturnType; }; function generateCurrentValue( @@ -106,7 +108,11 @@ function serializeVariants( .join('<%>'); } -export const useEditService = ({ translations, viewRefs }: Props) => { +export const useEditService = ({ + translations, + viewRefs, + taskService, +}: Props) => { const [position, setPosition] = useState(undefined); const currentIndex = useMemo(() => { return translations.fixedTranslations?.findIndex( @@ -367,8 +373,29 @@ export const useEditService = ({ translations, viewRefs }: Props) => { { keyId, value: { keyName: value } }, ]); } - doAfterCommand(data.after); + + if (language && !data.preventTaskResolution) { + const key = translations.fixedTranslations?.find( + (k) => k.keyId === keyId + ); + const task = key?.tasks?.find((t) => t.languageTag === language); + + if ( + task && + !task.done && + task.userAssigned && + task.type === 'TRANSLATE' + ) { + await taskService.setTaskTranslationState({ + keyId: position.keyId, + taskNumber: task.number, + done: true, + }); + } + } + data.onSuccess?.(); + doAfterCommand(data.after); }; const doAfterCommand = (command?: AfterCommand) => { diff --git a/webapp/src/views/projects/translations/context/services/useStateService.tsx b/webapp/src/views/projects/translations/context/services/useStateService.tsx index 29bdea7780..27b65a4ab0 100644 --- a/webapp/src/views/projects/translations/context/services/useStateService.tsx +++ b/webapp/src/views/projects/translations/context/services/useStateService.tsx @@ -3,17 +3,19 @@ import { useProject } from 'tg.hooks/useProject'; import { SetTranslationState } from '../types'; import { useTranslationsService } from './useTranslationsService'; +import { useTaskService } from './useTaskService'; type Props = { translations: ReturnType; + taskService: ReturnType; }; -export const useStateService = ({ translations }: Props) => { +export const useStateService = ({ translations, taskService }: Props) => { const putTranslationState = usePutTranslationState(); const project = useProject(); const changeState = (data: SetTranslationState) => - putTranslationState.mutate( + putTranslationState.mutateAsync( { path: { projectId: project.id, @@ -26,6 +28,22 @@ export const useStateService = ({ translations }: Props) => { translations.changeTranslations([ { keyId: data.keyId, language: data.language, value: response }, ]); + const key = translations.fixedTranslations?.find( + (k) => k.keyId === data.keyId + ); + const task = key?.tasks?.find((t) => t.languageTag === data.language); + if ( + data.state === 'REVIEWED' && + task?.userAssigned && + task.type === 'REVIEW' && + !task.done + ) { + taskService.setTaskTranslationState({ + keyId: data.keyId, + taskNumber: task.number, + done: true, + }); + } }, } ); diff --git a/webapp/src/views/projects/translations/context/services/useTaskService.tsx b/webapp/src/views/projects/translations/context/services/useTaskService.tsx new file mode 100644 index 0000000000..4f327bf059 --- /dev/null +++ b/webapp/src/views/projects/translations/context/services/useTaskService.tsx @@ -0,0 +1,80 @@ +import { + useFinishTask, + usePutTaskTranslation, +} from 'tg.service/TranslationHooks'; +import { useProject } from 'tg.hooks/useProject'; + +import { SetTaskTranslationState } from '../types'; +import { useTranslationsService } from './useTranslationsService'; +import { confirmation } from 'tg.hooks/confirmation'; +import { T } from '@tolgee/react'; + +type Props = { + translations: ReturnType; +}; + +export const useTaskService = ({ translations }: Props) => { + const project = useProject(); + const finishTask = useFinishTask(); + const putTaskTranslation = usePutTaskTranslation(); + + const handleFinishTask = (taskNumber: number) => { + return finishTask.mutateAsync({ + path: { projectId: project.id, taskNumber }, + }); + }; + + const setTaskTranslationState = (data: SetTaskTranslationState) => + putTaskTranslation.mutateAsync( + { + path: { + projectId: project.id, + taskNumber: data.taskNumber, + keyId: data.keyId, + }, + content: { + 'application/json': { + done: data.done, + }, + }, + }, + { + onSuccess(response) { + const key = translations.fixedTranslations?.find( + (t) => t.keyId === data.keyId + ); + translations.updateTranslationKeys([ + { + keyId: data.keyId, + value: { + tasks: key?.tasks?.map((t) => + t.number === data.taskNumber + ? { ...t, done: response.done } + : t + ), + }, + }, + ]); + if (response.taskFinished) { + confirmation({ + title: , + message: , + confirmButtonText: ( + + ), + onConfirm() { + handleFinishTask(data.taskNumber).then(() => { + translations.refetchTranslations(); + }); + }, + }); + } + }, + } + ); + + return { + setTaskTranslationState, + isLoading: putTaskTranslation.isLoading || finishTask.isLoading, + }; +}; diff --git a/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx b/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx index cca7533073..59d4efc0e9 100644 --- a/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx +++ b/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx @@ -143,9 +143,11 @@ export const useTranslationsService = (props: Props) => { search: urlSearch as string, filterRevisionId: props.prefilter?.activity !== undefined - ? [props.prefilter?.activity] + ? [props.prefilter.activity] : undefined, filterFailedKeysOfJob: props.prefilter?.failedJob, + filterTaskNumber: + props.prefilter?.task !== undefined ? [props.prefilter.task] : undefined, }; const translations = useApiInfiniteQuery({ diff --git a/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts b/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts index acdf69d31a..d25a5fd630 100644 --- a/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts +++ b/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts @@ -57,6 +57,13 @@ export const useTranslationsShortcuts = () => { return allLanguages?.find((l) => l.tag === langTag)?.id; }; + const getCurrentKey = () => { + const focused = getCurrentlyFocused(elementsRef.current); + if (focused?.language && focused.keyId) { + return fixedTranslations?.find((t) => t.keyId === focused.keyId); + } + }; + const getCurrentTranslation = () => { const focused = getCurrentlyFocused(elementsRef.current); if (focused?.language && focused.keyId) { @@ -93,15 +100,21 @@ export const useTranslationsShortcuts = () => { const getEnterHandler = () => { const focused = getCurrentlyFocused(elementsRef.current); if (focused) { - const canTranslate = satisfiesLanguageAccess( - 'translations.edit', - getLanguageId(focused.language) + const translation = getCurrentTranslation(); + const keyData = getCurrentKey(); + const firstTask = keyData?.tasks?.find( + (t) => t.languageTag === focused.language ); + const canTranslate = + satisfiesLanguageAccess( + 'translations.edit', + getLanguageId(focused.language) + ) || + (firstTask?.userAssigned && firstTask.type === 'TRANSLATE'); if ( (isTranslation(focused) && canTranslate) || (!isTranslation(focused) && canEditKey) ) { - const translation = getCurrentTranslation(); if (translation?.state === 'DISABLED') { return; } @@ -126,16 +139,21 @@ export const useTranslationsShortcuts = () => { const getChangeStateHandler = () => { const focused = getCurrentlyFocused(elementsRef.current); - const canTranslate = satisfiesLanguageAccess( - 'translations.state-edit', - getLanguageId(focused?.language) + const translation = fixedTranslations?.find( + (t) => t.keyId === focused?.keyId + )?.translations[focused?.language || '']; + const keyData = getCurrentKey(); + const firstTask = keyData?.tasks?.find( + (t) => t.languageTag === focused?.language ); + const canTranslate = + satisfiesLanguageAccess( + 'translations.state-edit', + getLanguageId(focused?.language) + ) || + (firstTask?.userAssigned && firstTask.type === 'REVIEW'); if (focused?.language && canTranslate) { - const translation = fixedTranslations?.find( - (t) => t.keyId === focused.keyId - )?.translations[focused.language]; - const newState = translation?.state && TRANSLATION_STATES[translation.state]?.next; diff --git a/webapp/src/views/projects/translations/context/types.ts b/webapp/src/views/projects/translations/context/types.ts index 2478d9f6bc..66106653f3 100644 --- a/webapp/src/views/projects/translations/context/types.ts +++ b/webapp/src/views/projects/translations/context/types.ts @@ -49,6 +49,7 @@ export type AfterCommand = 'EDIT_NEXT'; export type ChangeValue = { after?: AfterCommand; + preventTaskResolution?: boolean; onSuccess?: () => void; }; @@ -71,6 +72,12 @@ export type SetTranslationState = { state: StateInType; }; +export type SetTaskTranslationState = { + done: boolean; + taskNumber: number; + keyId: number; +}; + export type ChangeScreenshotNum = { keyId: number; screenshotCount: number | undefined; diff --git a/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx b/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx index 6d1ad0a803..9fee5d744a 100644 --- a/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx +++ b/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx @@ -1,4 +1,4 @@ -import { FilterList } from '@mui/icons-material'; +import { FilterLines } from '@untitled-ui/icons-react'; import { Button, styled, useMediaQuery } from '@mui/material'; import { T } from '@tolgee/react'; @@ -58,7 +58,7 @@ export const PrefilterContainer = ({ content, title }: Props) => { return ( - + {title} {!isSmall && content} diff --git a/webapp/src/views/projects/translations/prefilters/Prefilter.tsx b/webapp/src/views/projects/translations/prefilters/Prefilter.tsx index 7175dc0ee3..f930994626 100644 --- a/webapp/src/views/projects/translations/prefilters/Prefilter.tsx +++ b/webapp/src/views/projects/translations/prefilters/Prefilter.tsx @@ -1,5 +1,6 @@ import { PrefilterActivity } from './PrefilterActivity'; import { PrefilterFailedJob } from './PrefilterFailedJob'; +import { PrefilterTask } from './PrefilterTask'; import { PrefilterType } from './usePrefilter'; type Props = { @@ -11,6 +12,8 @@ export const Prefilter = ({ prefilter }: Props) => { return ; } else if (prefilter?.failedJob) { return ; + } else if (prefilter?.task) { + return ; } return null; }; diff --git a/webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx b/webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx new file mode 100644 index 0000000000..216fb4b8e5 --- /dev/null +++ b/webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { T } from '@tolgee/react'; +import { Box, Dialog, IconButton, styled, useTheme } from '@mui/material'; +import { AlertTriangle } from '@untitled-ui/icons-react'; + +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { TaskLabel } from 'tg.component/task/TaskLabel'; +import { PrefilterContainer } from './ContainerPrefilter'; +import { TaskDetail } from 'tg.component/task/TaskDetail'; +import { TaskTooltip } from 'tg.component/task/TaskTooltip'; +import { TaskDetail as TaskDetailIcon } from 'tg.component/CustomIcons'; +import { Link } from 'react-router-dom'; +import { getTaskRedirect } from 'tg.component/task/utils'; + +const StyledWarning = styled('div')` + display: flex; + align-items: center; + padding-left: 12px; + gap: 4px; +`; + +const StyledTaskId = styled(Link)` + text-decoration: underline; + text-underline-offset: 3px; + color: inherit; +`; + +type Props = { + taskNumber: number; +}; + +export const PrefilterTask = ({ taskNumber }: Props) => { + const project = useProject(); + const theme = useTheme(); + const [showDetails, setShowDetails] = useState(false); + + const { data } = useApiQuery({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}', + method: 'get', + path: { projectId: project.id, taskNumber }, + }); + + const blockingTasksLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/blocking-tasks', + method: 'get', + path: { projectId: project.id, taskNumber }, + }); + + if (!data) { + return null; + } + + function handleShowDetails() { + setShowDetails(true); + } + function handleDetailClose() { + setShowDetails(false); + } + return ( + <> + } + content={ + + + + + + {blockingTasksLoadable.data?.length ? ( + + + + {' '} + {blockingTasksLoadable.data.map((taskNumber, i) => ( + + + + #{taskNumber} + + + {i !== blockingTasksLoadable.data.length - 1 && ', '} + + ))} + + + ) : null} + + } + /> + {showDetails && ( + + + + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/prefilters/usePrefilter.ts b/webapp/src/views/projects/translations/prefilters/usePrefilter.ts index f45b9d3752..254f37fd14 100644 --- a/webapp/src/views/projects/translations/prefilters/usePrefilter.ts +++ b/webapp/src/views/projects/translations/prefilters/usePrefilter.ts @@ -3,6 +3,7 @@ import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; export type PrefilterType = { activity?: number; failedJob?: number; + task?: number; clear: () => void; }; @@ -23,13 +24,19 @@ export const usePrefilter = (): PrefilterType => { defaultVal: undefined, history: true, }); + const [task, setTask] = useUrlSearchState('task', { + defaultVal: undefined, + history: true, + }); const activityId = stringToNumber(activity); const failedJobId = stringToNumber(failedJob); + const taskNumber = stringToNumber(task); function clear() { setActivity(undefined); setFailedJob(undefined); + setTask(undefined); } const result: PrefilterType = { @@ -40,6 +47,8 @@ export const usePrefilter = (): PrefilterType => { result.activity = activityId; } else if (failedJobId !== undefined) { result.failedJob = failedJobId; + } else if (taskNumber !== undefined) { + result.task = taskNumber; } return result; diff --git a/webapp/src/views/projects/translations/useTranslationCell.ts b/webapp/src/views/projects/translations/useTranslationCell.ts index eb1f2e79b7..ae21dbb18b 100644 --- a/webapp/src/views/projects/translations/useTranslationCell.ts +++ b/webapp/src/views/projects/translations/useTranslationCell.ts @@ -18,6 +18,11 @@ import { useProject } from 'tg.hooks/useProject'; type LanguageModel = components['schemas']['LanguageModel']; +export type SaveProps = { + preventTaskResolution?: boolean; + after?: AfterCommand; +}; + type Props = { keyData: DeletableKeyWithTranslationsModelType; language: LanguageModel; @@ -41,6 +46,7 @@ export const useTranslationCell = ({ changeField, setEditForce, setTranslationState, + setTaskState, updateEdit, } = useTranslationsActions(); @@ -81,9 +87,10 @@ export const useTranslationCell = ({ }); }; - const handleSave = (after?: AfterCommand) => { + const handleSave = ({ after, preventTaskResolution }: SaveProps) => { changeField({ after, + preventTaskResolution, onSuccess: () => onSaveSuccess?.(value), }); }; @@ -140,6 +147,18 @@ export const useTranslationCell = ({ const translation = langTag ? keyData?.translations[langTag] : undefined; + const firstTask = keyData.tasks?.find((t) => t.languageTag === language.tag); + + const setAssignedTaskState = (done: boolean) => { + if (firstTask) { + setTaskState({ + keyId: keyData.keyId, + taskNumber: firstTask.number, + done, + }); + } + }; + const setState = () => { if (!translation) { return; @@ -159,14 +178,15 @@ export const useTranslationCell = ({ updateEdit({ activeVariant }); } - const canChangeState = satisfiesLanguageAccess( - 'translations.state-edit', - language.id - ); + const canChangeState = + (firstTask?.userAssigned && firstTask.type === 'REVIEW') || + satisfiesLanguageAccess('translations.state-edit', language.id); const disabled = translation?.state === 'DISABLED'; const editEnabled = - satisfiesLanguageAccess('translations.edit', language.id) && !disabled; + ((firstTask?.userAssigned && firstTask.type === 'TRANSLATE') || + satisfiesLanguageAccess('translations.edit', language.id)) && + !disabled; return { keyId, @@ -179,6 +199,7 @@ export const useTranslationCell = ({ setEditValueString, setState, setVariant, + setAssignedTaskState, value, editVal: isEditing ? cursor : undefined, isEditing, diff --git a/webapp/src/views/userSettings/apiKeys/ApiKeyListItem.tsx b/webapp/src/views/userSettings/apiKeys/ApiKeyListItem.tsx index 31bb238bc6..7e2e60ba1a 100644 --- a/webapp/src/views/userSettings/apiKeys/ApiKeyListItem.tsx +++ b/webapp/src/views/userSettings/apiKeys/ApiKeyListItem.tsx @@ -6,7 +6,7 @@ import { Box, Button, styled } from '@mui/material'; import { Link } from 'react-router-dom'; import { LINKS, PARAMS } from 'tg.constants/links'; import { useMessage } from 'tg.hooks/useSuccessMessage'; -import { Edit } from '@mui/icons-material'; +import { Edit02 } from '@untitled-ui/icons-react'; import { ApiKeyExpiryInfo } from './ApiKeyExpiryInfo'; import { NewApiKeyInfo } from './NewApiKeyInfo'; @@ -105,7 +105,7 @@ export const ApiKeyListItem = (props: { })} > {props.apiKey.description} - {' '} + = (props) => { fullWidth name="projectId" label="Project" + minHeight={false} renderValue={(v) => projects.data?._embedded?.projects?.find( (r) => r.id === v diff --git a/webapp/src/views/userSettings/pats/PatListItem.tsx b/webapp/src/views/userSettings/pats/PatListItem.tsx index 9bdccf7529..345ac96a1c 100644 --- a/webapp/src/views/userSettings/pats/PatListItem.tsx +++ b/webapp/src/views/userSettings/pats/PatListItem.tsx @@ -7,7 +7,7 @@ import { confirmation } from 'tg.hooks/confirmation'; import { PatExpiryInfo } from './PatExpiryInfo'; import { LINKS, PARAMS } from 'tg.constants/links'; import { Link } from 'react-router-dom'; -import { Edit } from '@mui/icons-material'; +import { Edit02 } from '@untitled-ui/icons-react'; import { NewTokenInfo } from './NewTokenInfo'; const StyledRoot = styled(Box)` @@ -89,7 +89,7 @@ export function PatListItem(props: { })} > {props.pat.description} - {' '} + {props.pat.lastUsedAt ? ( From a93ee2a0e385a4eedf54b98f2bbf20c135a5357e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Mon, 16 Sep 2024 16:27:01 +0200 Subject: [PATCH 02/26] fix: search field min height --- webapp/src/component/layout/HeaderSearchField.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/src/component/layout/HeaderSearchField.tsx b/webapp/src/component/layout/HeaderSearchField.tsx index 3da1e3e6c6..a4ed50cb0e 100644 --- a/webapp/src/component/layout/HeaderSearchField.tsx +++ b/webapp/src/component/layout/HeaderSearchField.tsx @@ -20,6 +20,7 @@ export const HeaderSearchField = ( } + minHeight={false} InputProps={{ startAdornment: ( From 556a1b7d5356775184fe9aa990f6ea3d1c287443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Tue, 17 Sep 2024 10:22:45 +0200 Subject: [PATCH 03/26] chore: fix BE tests --- .../controllers/task/TaskControllerPermissionsTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt index 2bce6bbedf..698e88add9 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt @@ -53,10 +53,12 @@ class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") fun `can do necessary translate task's operations`() { userAccount = testData.projectUser.self - performProjectAuthPut( - "tasks/${testData.translateTask.self.number}/keys/${testData.keysInTask.first().self.id}", - UpdateTaskKeyRequest(done = true), - ).andIsOk + testData.keysInTask.forEach { + performProjectAuthPut( + "tasks/${testData.translateTask.self.number}/keys/${it.self.id}", + UpdateTaskKeyRequest(done = true), + ).andIsOk + } performProjectAuthPost("tasks/${testData.translateTask.self.number}/finish").andIsOk } From f095ae249a1a57c3baed76cb83521b259562b13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Tue, 17 Sep 2024 10:25:20 +0200 Subject: [PATCH 04/26] chore: fix BE tests --- .../controllers/task/TaskControllerPermissionsTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt index 698e88add9..09bcbc82ca 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerPermissionsTest.kt @@ -25,7 +25,7 @@ class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") @Test @ProjectJWTAuthTestMethod - fun `sees only translate tasks in user tasks`() { + fun `sees only tasks assigned to him`() { // is assigned to translate task userAccount = testData.projectUser.self @@ -39,7 +39,7 @@ class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") @Test @ProjectJWTAuthTestMethod - fun `can access translate task's data`() { + fun `can access assigned task`() { userAccount = testData.projectUser.self performProjectAuthGet("tasks/${testData.translateTask.self.number}").andIsOk @@ -50,7 +50,7 @@ class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") @Test @ProjectJWTAuthTestMethod - fun `can do necessary translate task's operations`() { + fun `can do assigned task's basic operations`() { userAccount = testData.projectUser.self testData.keysInTask.forEach { @@ -64,7 +64,7 @@ class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") @Test @ProjectJWTAuthTestMethod - fun `can't do advanced translate task's operations`() { + fun `can't do advanced assigned task's operations`() { userAccount = testData.projectUser.self performProjectAuthPut( @@ -79,7 +79,7 @@ class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") @Test @ProjectJWTAuthTestMethod - fun `can't access review task's data`() { + fun `can't access unassigned review task's data`() { userAccount = testData.projectUser.self performProjectAuthGet("tasks/${testData.reviewTask.self.number}").andIsForbidden From 378d93e2d4bbdf9824d85fe74758015d13a1e632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Wed, 18 Sep 2024 09:23:36 +0200 Subject: [PATCH 05/26] chore: e2e tests for tasks --- .../testDataBuilder/data/TaskTestData.kt | 8 +- .../internal/e2eData/TaskE2eDataController.kt | 28 ++++ .../common/apiCalls/testData/testData.ts | 2 + e2e/cypress/common/tasks.ts | 38 +++++ e2e/cypress/e2e/tasks/createTask.cy.ts | 150 ++++++++++++++++++ e2e/cypress/e2e/tasks/projectTasks.cy.ts | 63 ++++++++ e2e/cypress/support/dataCyType.d.ts | 39 ++++- webapp/src/component/UserAccount.tsx | 3 +- webapp/src/component/task/TaskAssignees.tsx | 2 +- webapp/src/component/task/TaskDatePicker.tsx | 2 +- webapp/src/component/task/TaskDetail.tsx | 8 + webapp/src/component/task/TaskInfoItem.tsx | 5 +- webapp/src/component/task/TaskItem.tsx | 4 +- webapp/src/component/task/TaskLabel.tsx | 4 +- webapp/src/component/task/TaskScope.tsx | 8 +- .../assigneeSelect/AssigneeSearchSelect.tsx | 2 +- .../AssigneeSearchSelectPopover.tsx | 1 + .../task/taskCreate/TaskCreateDialog.tsx | 22 ++- .../component/task/taskCreate/TaskPreview.tsx | 19 ++- .../taskCreate/TranslationStateFilter.tsx | 5 +- .../task/taskFilter/SubfilterLanguages.tsx | 1 + .../component/task/taskFilter/TaskFilter.tsx | 2 +- .../task/taskFilter/TaskFilterPopover.tsx | 1 + .../task/tasksHeader/TasksHeaderBig.tsx | 4 +- .../views/projects/tasks/ProjectTasksView.tsx | 1 + 25 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/TaskE2eDataController.kt create mode 100644 e2e/cypress/common/tasks.ts create mode 100644 e2e/cypress/e2e/tasks/createTask.cy.ts create mode 100644 e2e/cypress/e2e/tasks/projectTasks.cy.ts 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 0ee897afe0..a41d8ea8cc 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 @@ -7,7 +7,7 @@ import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope import io.tolgee.model.enums.TaskType -class TaskTestData : BaseTestData("tagsTestUser", "tagsTestProject") { +class TaskTestData : BaseTestData("tasksTestUser", "Project with tasks") { var projectUser: UserAccountBuilder var orgAdmin: UserAccountBuilder var orgMember: UserAccountBuilder @@ -30,6 +30,7 @@ class TaskTestData : BaseTestData("tagsTestUser", "tagsTestProject") { projectUser.self.apply { username = "Project user" + name = "Project user" } root.data.userAccounts.add(projectUser) @@ -37,24 +38,28 @@ class TaskTestData : BaseTestData("tagsTestUser", "tagsTestProject") { orgMember.self.apply { username = "Organization member" + name = "Organization member" } orgAdmin = UserAccountBuilder(root) orgAdmin.self.apply { username = "Organization owner" + name = "Organization owner" } projectViewScopeUser = UserAccountBuilder(root) projectViewScopeUser.self.apply { username = "Project view scope user (en)" + name = "Project view scope user (en)" } projectViewRoleUser = UserAccountBuilder(root) projectViewRoleUser.self.apply { username = "Project view role user (en)" + name = "Project view role user (en)" } userAccountBuilder.defaultOrganizationBuilder.apply { @@ -178,6 +183,7 @@ class TaskTestData : BaseTestData("tagsTestUser", "tagsTestProject") { unrelatedUser.self.apply { username = "Unrelated user" + name = "Unrelated user" } unrelatedProject.apply { 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 new file mode 100644 index 0000000000..46e39e2ea7 --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/TaskE2eDataController.kt @@ -0,0 +1,28 @@ +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 org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + + +@RestController +@CrossOrigin(origins = ["*"]) +@Hidden +@RequestMapping(value = ["internal/e2e-data/task"]) +@Transactional +class TaskE2eDataController(): AbstractE2eDataController() { + + @GetMapping(value = ["/generate"]) + @Transactional + fun generateBasicTestData() { + val data = TaskTestData() + testDataService.saveTestData(data.root) + } + override val testData: TestDataBuilder + get() = TaskTestData().root +} diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index d24e0ee9bd..27061a87ff 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -104,6 +104,8 @@ export const formerUserTestData = generateTestDataObject('former-user'); export const namespaces = generateTestDataObject('namespaces'); +export const tasks = generateTestDataObject('task'); + export const batchJobs = generateTestDataObject('batch-jobs'); export const sensitiveOperationProtectionTestData = { diff --git a/e2e/cypress/common/tasks.ts b/e2e/cypress/common/tasks.ts new file mode 100644 index 0000000000..7586b533a6 --- /dev/null +++ b/e2e/cypress/common/tasks.ts @@ -0,0 +1,38 @@ +import { HOST } from './constants'; + +export const visitTasks = (projectId: number) => { + return cy.visit(`${HOST}/projects/${projectId}/tasks`); +}; + +export function getTaskPreview(language: string) { + return cy + .gcy('task-preview-language') + .contains(language) + .closestDcy('task-preview'); +} + +export function checkTaskPreview({ + language, + keys, + alert, + words, + characters, +}: { + language: string; + keys: number; + alert: boolean; + words: number; + characters: number; +}) { + getTaskPreview(language) + .findDcy('task-preview-keys') + .should('contain', keys) + .findDcy('task-preview-alert') + .should(alert ? 'exist' : 'not.exist'); + getTaskPreview(language) + .findDcy('task-preview-words') + .should('contain', words); + getTaskPreview(language) + .findDcy('task-preview-characters') + .should('contain', characters); +} diff --git a/e2e/cypress/e2e/tasks/createTask.cy.ts b/e2e/cypress/e2e/tasks/createTask.cy.ts new file mode 100644 index 0000000000..234f02af3a --- /dev/null +++ b/e2e/cypress/e2e/tasks/createTask.cy.ts @@ -0,0 +1,150 @@ +import { login } from '../../common/apiCalls/common'; +import { tasks } from '../../common/apiCalls/testData/testData'; +import { waitForGlobalLoading } from '../../common/loading'; +import { assertMessage, dismissMenu } from '../../common/shared'; +import { + visitTasks, + getTaskPreview, + checkTaskPreview, +} from '../../common/tasks'; + +describe('create task', () => { + beforeEach(() => { + tasks.clean({ failOnStatusCode: false }); + tasks + .generateStandard() + .then((r) => r.body) + .then(({ users, projects }) => { + login(users[0].username); + const testProject = projects.find( + ({ name }) => name === 'Project with tasks' + ); + visitTasks(testProject.id); + }); + waitForGlobalLoading(); + }); + + it('creates task from project tasks view', () => { + cy.gcy('tasks-header-add-task').click(); + cy.gcy('create-task-field-type').click(); + cy.gcy('create-task-field-type-item').contains('Review').click(); + cy.gcy('create-task-field-name').type('New review task'); + cy.gcy('create-task-field-languages').click(); + cy.gcy('create-task-field-languages-item').contains('Czech').click(); + dismissMenu(); + cy.gcy('task-date-picker').type('01/31/2025'); + cy.gcy('create-task-field-description').type( + 'This is task description ...' + ); + getTaskPreview('Czech').findDcy('assignee-select').click(); + cy.gcy('assignee-search-select-popover') + .contains('Organization member') + .click(); + dismissMenu(); + + cy.gcy('create-task-submit').click(); + + assertMessage('1 task created'); + + cy.gcy('task-label-name') + .contains('New review task') + .should('be.visible') + .closestDcy('task-item') + .findDcy('task-item-detail') + .click(); + + cy.gcy('task-detail-field-name') + .find('input') + .should('have.value', 'New review task'); + cy.gcy('assignee-select').should('contain', 'Organization member'); + cy.gcy('task-date-picker').find('input').should('have.value', '01/31/2025'); + cy.gcy('task-detail-author').should('contain', 'tasksTestUser'); + cy.gcy('task-detail-created-at').should( + 'contain', + new Date(Date.now()).getFullYear() + ); + cy.gcy('task-detail-closed-at').should( + 'not.contain', + new Date(Date.now()).getFullYear() + ); + cy.gcy('task-detail-project').should('contain', 'Project with tasks'); + }); + + it('task create displays correct numbers for translate task', () => { + cy.gcy('tasks-header-add-task').click(); + cy.gcy('create-task-field-languages').click(); + cy.gcy('create-task-field-languages-item').contains('Czech').click(); + cy.gcy('create-task-field-languages-item').contains('English').click(); + dismissMenu(); + cy.waitForDom(); + + checkTaskPreview({ + language: 'Czech', + keys: 4, + alert: false, + words: 8, + characters: 52, + }); + checkTaskPreview({ + language: 'English', + keys: 2, + alert: true, + words: 4, + characters: 26, + }); + }); + + it('task create displays correct numbers for review task', () => { + cy.gcy('tasks-header-add-task').click(); + cy.gcy('create-task-field-type').click(); + cy.gcy('create-task-field-type-item').contains('Review').click(); + cy.gcy('create-task-field-languages').click(); + cy.gcy('create-task-field-languages-item').contains('Czech').click(); + cy.gcy('create-task-field-languages-item').contains('English').click(); + dismissMenu(); + cy.waitForDom(); + + checkTaskPreview({ + language: 'Czech', + keys: 2, + alert: true, + words: 4, + characters: 26, + }); + checkTaskPreview({ + language: 'English', + keys: 4, + alert: false, + words: 8, + characters: 52, + }); + }); + + it('task create displays correct numbers for filter', () => { + cy.gcy('tasks-header-add-task').click(); + cy.gcy('create-task-field-languages').click(); + cy.gcy('create-task-field-languages-item').contains('Czech').click(); + cy.gcy('create-task-field-languages-item').contains('English').click(); + dismissMenu(); + + cy.gcy('translations-state-filter').click(); + cy.gcy('translations-state-filter-option').contains('Untranslated').click(); + dismissMenu(); + cy.waitForDom(); + + checkTaskPreview({ + language: 'Czech', + keys: 2, + alert: true, + words: 4, + characters: 26, + }); + checkTaskPreview({ + language: 'English', + keys: 0, + alert: true, + words: 0, + characters: 0, + }); + }); +}); diff --git a/e2e/cypress/e2e/tasks/projectTasks.cy.ts b/e2e/cypress/e2e/tasks/projectTasks.cy.ts new file mode 100644 index 0000000000..0a29f26802 --- /dev/null +++ b/e2e/cypress/e2e/tasks/projectTasks.cy.ts @@ -0,0 +1,63 @@ +import { login } from '../../common/apiCalls/common'; +import { tasks } from '../../common/apiCalls/testData/testData'; +import { waitForGlobalLoading } from '../../common/loading'; +import { visitTasks } from '../../common/tasks'; + +describe('project tasks', () => { + beforeEach(() => { + tasks.clean({ failOnStatusCode: false }); + tasks + .generateStandard() + .then((r) => r.body) + .then(({ users, projects }) => { + login(users[0].username); + const testProject = projects.find( + ({ name }) => name === 'Project with tasks' + ); + visitTasks(testProject.id); + }); + waitForGlobalLoading(); + }); + + it('shows project tasks correctly', () => { + cy.gcy('task-item').should('have.length', 2); + cy.gcy('task-item').contains('Translate task').should('be.visible'); + cy.gcy('task-item').contains('Review task').should('be.visible'); + + cy.gcy('task-item') + .contains('Translate task') + .closestDcy('task-item') + .findDcy('task-item-detail') + .click(); + + cy.gcy('task-detail-keys').contains(2).should('be.visible'); + cy.gcy('task-detail-words').contains(4).should('be.visible'); + cy.gcy('task-detail-characters').contains(26).should('be.visible'); + }); + + it('filters project tasks by type', () => { + cy.gcy('task-item').should('have.length', 2); + cy.gcy('tasks-header-filter-select').click(); + cy.gcy('tasks-filter-menu').contains('Translate').click(); + cy.gcy('task-item').should('have.length', 1); + cy.gcy('task-item').contains('Translate task').should('exist'); + }); + + it('filters project tasks by language', () => { + cy.gcy('task-item').should('have.length', 2); + cy.gcy('tasks-header-filter-select').click(); + cy.gcy('tasks-filter-menu').contains('Language').click(); + cy.gcy('language-select-popover').contains('Czech').click(); + cy.gcy('task-item').should('have.length', 1); + cy.gcy('task-item').contains('Review task').should('exist'); + }); + + it('filters project tasks by assignee', () => { + cy.gcy('task-item').should('have.length', 2); + cy.gcy('tasks-header-filter-select').click(); + cy.gcy('tasks-filter-menu').contains('Assignees').click(); + cy.gcy('assignee-search-select-popover').contains('Project user').click(); + cy.gcy('task-item').should('have.length', 1); + cy.gcy('task-item').contains('Translate task').should('exist'); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index ed3845752b..fa01f196ac 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -73,6 +73,7 @@ declare namespace DataCy { "api-key-list-item-regenerate-button" | "api-keys-create-edit-dialog" | "api-keys-project-select-item" | + "assignee-search-select-popover" | "assignee-select" | "auto-avatar-img" | "avatar-image" | @@ -138,6 +139,13 @@ declare namespace DataCy { "content-delivery-storage-selector" | "content-delivery-storage-selector-item" | "content-delivery-subtitle" | + "create-task-field-description" | + "create-task-field-languages" | + "create-task-field-languages-item" | + "create-task-field-name" | + "create-task-field-type" | + "create-task-field-type-item" | + "create-task-submit" | "dashboard-projects-list-item" | "default-namespace-select" | "delete-user-button" | @@ -261,6 +269,7 @@ declare namespace DataCy { "language-ai-prompt-dialog-save" | "language-delete-button" | "language-modify-form" | + "language-select-popover" | "languages-add-dialog-submit" | "languages-create-autocomplete-field" | "languages-create-autocomplete-suggested-option" | @@ -471,10 +480,36 @@ declare namespace DataCy { "storage-subtitle" | "tag-autocomplete-input" | "tag-autocomplete-option" | + "task-date-picker" | + "task-detail-author" | + "task-detail-characters" | + "task-detail-closed-at" | + "task-detail-created-at" | + "task-detail-download-report" | + "task-detail-field-description" | + "task-detail-field-name" | + "task-detail-keys" | + "task-detail-project" | + "task-detail-submit" | + "task-detail-words" | + "task-item" | + "task-item-detail" | + "task-item-menu" | + "task-label-name" | + "task-preview" | + "task-preview-alert" | + "task-preview-characters" | + "task-preview-keys" | + "task-preview-language" | + "task-preview-words" | "task-select-item" | "task-select-search" | + "tasks-filter-menu" | + "tasks-header-add-task" | + "tasks-header-filter-select" | + "tasks-header-show-closed" | + "tasks-view-board-button" | "tasks-view-list-button" | - "tasks-view-table-button" | "this-is-the-element" | "top-banner" | "top-banner-content" | @@ -530,6 +565,8 @@ declare namespace DataCy { "translations-row-checkbox" | "translations-select-all-button" | "translations-shortcuts-command" | + "translations-state-filter" | + "translations-state-filter-option" | "translations-state-indicator" | "translations-table-cell" | "translations-table-cell-language" | diff --git a/webapp/src/component/UserAccount.tsx b/webapp/src/component/UserAccount.tsx index 6fb786b3d5..c93ea98af6 100644 --- a/webapp/src/component/UserAccount.tsx +++ b/webapp/src/component/UserAccount.tsx @@ -15,7 +15,6 @@ const StyledOrgItem = styled('div')` display: flex; gap: 8px; align-items: center; - text: ${({ theme }) => theme.palette.primaryText}; `; type Props = { @@ -37,7 +36,7 @@ export const UserAccount = ({ user }: Props) => { size={22} /> - {user.name} + {user.name || user.username} ); }; diff --git a/webapp/src/component/task/TaskAssignees.tsx b/webapp/src/component/task/TaskAssignees.tsx index 6f083b9054..617a0affe5 100644 --- a/webapp/src/component/task/TaskAssignees.tsx +++ b/webapp/src/component/task/TaskAssignees.tsx @@ -26,7 +26,7 @@ const renderAssignees = (assignees: SimpleUserAccountModel[]) => { {assignees.map((user) => ( {user.username}} + title={
{user.name || user.username}
} disableInteractive >
diff --git a/webapp/src/component/task/TaskDatePicker.tsx b/webapp/src/component/task/TaskDatePicker.tsx index fc4d6b462a..11930db837 100644 --- a/webapp/src/component/task/TaskDatePicker.tsx +++ b/webapp/src/component/task/TaskDatePicker.tsx @@ -5,7 +5,7 @@ import { TextField as NonFormTextField } from 'tg.component/common/TextField'; import { Calendar } from '@untitled-ui/icons-react'; const DueDatePicker = (props: ComponentProps) => { - return ; + return ; }; type Props = { diff --git a/webapp/src/component/task/TaskDetail.tsx b/webapp/src/component/task/TaskDetail.tsx index 3ef6b12178..9b282b21f1 100644 --- a/webapp/src/component/task/TaskDetail.tsx +++ b/webapp/src/component/task/TaskDetail.tsx @@ -155,6 +155,7 @@ export const TaskDetail = ({ task, onClose, projectId }: Props) => { @@ -178,6 +179,7 @@ export const TaskDetail = ({ task, onClose, projectId }: Props) => { { disableInteractive > downloadReport(projectId, data)} > @@ -228,18 +231,22 @@ export const TaskDetail = ({ task, onClose, projectId }: Props) => { } + data-cy="task-detail-author" /> @@ -251,6 +258,7 @@ export const TaskDetail = ({ task, onClose, projectId }: Props) => { loading={isSubmitting} disabled={!dirty} type="submit" + data-cy="task-detail-submit" onClick={() => submitForm()} > {t('task_detail_submit_button')} diff --git a/webapp/src/component/task/TaskInfoItem.tsx b/webapp/src/component/task/TaskInfoItem.tsx index 91dcf04094..b87f49176e 100644 --- a/webapp/src/component/task/TaskInfoItem.tsx +++ b/webapp/src/component/task/TaskInfoItem.tsx @@ -7,11 +7,12 @@ const StyledLabel = styled(Box)` type Props = { label: React.ReactNode; value: React.ReactNode; + 'data-cy'?: string; }; -export const TaskInfoItem = ({ label, value }: Props) => { +export const TaskInfoItem = ({ label, value, ...other }: Props) => { return ( - + {label} {value} diff --git a/webapp/src/component/task/TaskItem.tsx b/webapp/src/component/task/TaskItem.tsx index 824e19c869..9c0564967f 100644 --- a/webapp/src/component/task/TaskItem.tsx +++ b/webapp/src/component/task/TaskItem.tsx @@ -83,7 +83,7 @@ export const TaskItem = ({ }; return ( - + @@ -132,12 +132,14 @@ export const TaskItem = ({ onDetailOpen(task))} + data-cy="task-item-detail" > setAnchorEl(e.currentTarget))} + data-cy="task-item-menu" > diff --git a/webapp/src/component/task/TaskLabel.tsx b/webapp/src/component/task/TaskLabel.tsx index 710dcab66c..7b57359216 100644 --- a/webapp/src/component/task/TaskLabel.tsx +++ b/webapp/src/component/task/TaskLabel.tsx @@ -47,7 +47,9 @@ export const TaskLabel = ({ - {task.name} + + {task.name} + {project ? ( ) : ( diff --git a/webapp/src/component/task/TaskScope.tsx b/webapp/src/component/task/TaskScope.tsx index 3d1695337a..0c1ba0a1ce 100644 --- a/webapp/src/component/task/TaskScope.tsx +++ b/webapp/src/component/task/TaskScope.tsx @@ -49,9 +49,11 @@ export const TaskScope = ({ task, perUserData }: Props) => { {t('task_scope_characters_label')} {t('task_scope_total_to_translate')} - {formatNumber(task.totalItems)} - {formatNumber(task.baseWordCount)} - {formatNumber(task.baseCharacterCount)} + {formatNumber(task.totalItems)} + {formatNumber(task.baseWordCount)} + + {formatNumber(task.baseCharacterCount)} + diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx index bd6567f49e..62d9e29c50 100644 --- a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx @@ -64,7 +64,7 @@ export const AssigneeSearchSelect: React.FC = ({ u.name).join(', ')} + value={value.map((u) => u.name || u.username).join(', ')} data-cy="assignee-select" minHeight={false} label={label} diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx index fd7c47d641..cf65f545ca 100644 --- a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx @@ -152,6 +152,7 @@ export const AssigneeSearchSelectPopover: React.FC = ({ { onSelect?.(selection); onClose(); diff --git a/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx b/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx index be4838ff39..ba3fce3339 100644 --- a/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx +++ b/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx @@ -206,9 +206,14 @@ export const TaskCreateDialog = ({ size="small" renderValue={(v) => translateTaskType(v)} fullWidth + data-cy="create-task-field-type" > {TASK_TYPES.map((v) => ( - + {translateTaskType(v)} ))} @@ -216,10 +221,12 @@ export const TaskCreateDialog = ({ - setFieldValue('dueDate', value)} - label={t('create_task_field_due_date')} /> {t('create_task_submit_button')} diff --git a/webapp/src/component/task/taskCreate/TaskPreview.tsx b/webapp/src/component/task/taskCreate/TaskPreview.tsx index a95928a71a..67ee8f1acb 100644 --- a/webapp/src/component/task/taskCreate/TaskPreview.tsx +++ b/webapp/src/component/task/taskCreate/TaskPreview.tsx @@ -82,10 +82,13 @@ export const TaskPreview = ({ }); return ( - + - + {language.name} @@ -93,13 +96,17 @@ export const TaskPreview = ({ {t('create_task_preview_keys')} - + {statsLoadable.data ? ( {formatNumber(statsLoadable.data.keyCount)} {statsLoadable.data.keyCount !== keys.length && ( - + {t('create_task_preview_words')} - + {statsLoadable.data ? ( formatNumber(statsLoadable.data.wordCount) ) : ( @@ -125,7 +132,7 @@ export const TaskPreview = ({ {t('create_task_preview_characters')} - + {statsLoadable.data ? ( formatNumber(statsLoadable.data.characterCount) ) : ( diff --git a/webapp/src/component/task/taskCreate/TranslationStateFilter.tsx b/webapp/src/component/task/taskCreate/TranslationStateFilter.tsx index af9a2be800..0c713195d0 100644 --- a/webapp/src/component/task/taskCreate/TranslationStateFilter.tsx +++ b/webapp/src/component/task/taskCreate/TranslationStateFilter.tsx @@ -69,6 +69,7 @@ export const TranslationStateFilter = ({ {placeholder} ) } + data-cy="translations-state-filter" placeholder={placeholder} size="small" endAdornment={ @@ -94,7 +95,7 @@ export const TranslationStateFilter = ({ {...{ sx, className }} > @@ -109,7 +110,7 @@ export const TranslationStateFilter = ({ {Object.entries(TRANSLATION_STATES).map(([key, state]) => ( { onClose={() => { setOpen(false); }} + data-cy="language-select-popover" > {languages.map((lang) => ( = ({ return ( onShowClosedChange(!showClosed)} control={} + data-cy="tasks-header-show-closed" label={ {t('tasks_show_closed_label')} @@ -120,7 +121,7 @@ export const TasksHeaderBig = ({ onViewChange('BOARD')} - data-cy="tasks-view-table-button" + data-cy="tasks-view-board-button" > @@ -132,6 +133,7 @@ export const TasksHeaderBig = ({ color="primary" startIcon={} onClick={onAddTask} + data-cy="tasks-header-add-task" > {t('tasks_add')} diff --git a/webapp/src/views/projects/tasks/ProjectTasksView.tsx b/webapp/src/views/projects/tasks/ProjectTasksView.tsx index 50d9f9af0b..298dc2722a 100644 --- a/webapp/src/views/projects/tasks/ProjectTasksView.tsx +++ b/webapp/src/views/projects/tasks/ProjectTasksView.tsx @@ -118,6 +118,7 @@ export const ProjectTasksView = () => { view={view as TaskView} onViewChange={setView} isSmall={isSmall} + project={project} /> {view === 'LIST' && !isSmall ? ( From 3fd0de914cc6f92c5d2746551a75d5001075d946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 13:17:52 +0200 Subject: [PATCH 06/26] fix: my tasks e2e + fix some bugs --- e2e/cypress/common/tasks.ts | 4 + e2e/cypress/e2e/tasks/createTask.cy.ts | 2 - e2e/cypress/e2e/tasks/myTasks.cy.ts | 95 +++++++++++++++++++ e2e/cypress/support/dataCyType.d.ts | 5 + webapp/src/component/task/TaskScope.tsx | 20 +++- webapp/src/component/task/TaskState.tsx | 2 +- .../task/taskCreate/TaskCreateDialog.tsx | 12 +-- .../taskSelect/TaskSearchSelectPopover.tsx | 11 +-- .../BatchOperations/BatchSelect.tsx | 16 +++- .../BatchOperations/OperationTaskCreate.tsx | 12 ++- .../OperationsSummary/BatchProgress.tsx | 6 +- .../context/TranslationsContext.ts | 2 + .../context/services/useEditService.tsx | 6 +- .../context/services/useStateService.tsx | 9 +- 14 files changed, 171 insertions(+), 31 deletions(-) create mode 100644 e2e/cypress/e2e/tasks/myTasks.cy.ts diff --git a/e2e/cypress/common/tasks.ts b/e2e/cypress/common/tasks.ts index 7586b533a6..1d964af530 100644 --- a/e2e/cypress/common/tasks.ts +++ b/e2e/cypress/common/tasks.ts @@ -4,6 +4,10 @@ export const visitTasks = (projectId: number) => { return cy.visit(`${HOST}/projects/${projectId}/tasks`); }; +export const visitMyTasks = () => { + return cy.visit(`${HOST}/my-tasks`); +}; + export function getTaskPreview(language: string) { return cy .gcy('task-preview-language') diff --git a/e2e/cypress/e2e/tasks/createTask.cy.ts b/e2e/cypress/e2e/tasks/createTask.cy.ts index 234f02af3a..19c34dfad1 100644 --- a/e2e/cypress/e2e/tasks/createTask.cy.ts +++ b/e2e/cypress/e2e/tasks/createTask.cy.ts @@ -32,7 +32,6 @@ describe('create task', () => { cy.gcy('create-task-field-languages').click(); cy.gcy('create-task-field-languages-item').contains('Czech').click(); dismissMenu(); - cy.gcy('task-date-picker').type('01/31/2025'); cy.gcy('create-task-field-description').type( 'This is task description ...' ); @@ -57,7 +56,6 @@ describe('create task', () => { .find('input') .should('have.value', 'New review task'); cy.gcy('assignee-select').should('contain', 'Organization member'); - cy.gcy('task-date-picker').find('input').should('have.value', '01/31/2025'); cy.gcy('task-detail-author').should('contain', 'tasksTestUser'); cy.gcy('task-detail-created-at').should( 'contain', diff --git a/e2e/cypress/e2e/tasks/myTasks.cy.ts b/e2e/cypress/e2e/tasks/myTasks.cy.ts new file mode 100644 index 0000000000..b4a3d662ff --- /dev/null +++ b/e2e/cypress/e2e/tasks/myTasks.cy.ts @@ -0,0 +1,95 @@ +import { login } from '../../common/apiCalls/common'; +import { TestDataStandardResponse } from '../../common/apiCalls/testData/generator'; +import { tasks } from '../../common/apiCalls/testData/testData'; +import { gcyAdvanced } from '../../common/shared'; +import { getCell, setStateToReviewed } from '../../common/state'; +import { visitMyTasks } from '../../common/tasks'; +import { editCell } from '../../common/translations'; + +describe('my tasks', () => { + let testData: TestDataStandardResponse; + beforeEach(() => { + tasks.clean({ failOnStatusCode: false }); + tasks + .generateStandard() + .then((r) => r.body) + .then((data) => { + testData = data; + }); + }); + + function goToUserTasks(user: string) { + login( + testData.users.find((u) => [u.username, u.name].includes(user))?.username + ); + visitMyTasks(); + } + + it('shows tasks for tasksTestUser', () => { + goToUserTasks('tasksTestUser'); + cy.gcy('task-item').should('have.length', 2); + }); + + it('shows no tasks for Unrelated user', () => { + goToUserTasks('Unrelated user'); + cy.gcy('task-item').should('have.length', 0); + }); + + it('shows tasks for Organization member', () => { + goToUserTasks('Organization member'); + cy.gcy('task-item').should('have.length', 1); + cy.gcy('task-item').contains('Review task').should('be.visible'); + }); + + it('shows tasks for Project user', () => { + goToUserTasks('Project user'); + cy.gcy('task-item').should('have.length', 1); + cy.gcy('task-item').contains('Translate task').should('be.visible'); + }); + + it('Project member can finish Translate task', () => { + goToUserTasks('Project user'); + cy.gcy('task-label-name').contains('Translate task').click(); + 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') + .should('be.visible'); + cy.gcy('global-confirmation-confirm').click(); + visitMyTasks(); + cy.gcy('task-item') + .contains('Translate task') + .closestDcy('task-item') + .findDcy('task-state') + .should('contain', 'Done'); + }); + + it('Organization member can finish Review task', () => { + goToUserTasks('Organization member'); + cy.gcy('task-label-name').contains('Review task').click(); + setStateToReviewed('Překlad 0'); + getCell('Překlad 1').findDcy('translations-cell-task-button').click(); + cy.get('#alert-dialog-title') + .contains('All items in the task are finished') + .should('be.visible'); + cy.gcy('global-confirmation-confirm').click(); + visitMyTasks(); + cy.gcy('task-item') + .contains('Review task') + .closestDcy('task-item') + .findDcy('task-state') + .should('contain', 'Done'); + }); + + it('shows stats correctly on half finished task', () => { + goToUserTasks('Organization member'); + cy.gcy('task-label-name').contains('Review task').click(); + setStateToReviewed('Překlad 0'); + visitMyTasks(); + gcyAdvanced({ value: 'batch-progress', progress: '50' }).should('exist'); + cy.gcy('task-item-detail').click(); + gcyAdvanced({ value: 'task-detail-user-keys' }).should('contain', 1); + gcyAdvanced({ value: 'task-detail-user-words' }).should('contain', 2); + gcyAdvanced({ value: 'task-detail-user-characters' }).should('contain', 13); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index fa01f196ac..b27bf30438 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -91,6 +91,7 @@ declare namespace DataCy { "batch-operations-section" | "batch-operations-select" | "batch-operations-submit-button" | + "batch-progress" | "batch-select-item" | "billing-actual-extra-credits" | "billing-actual-period" | @@ -491,6 +492,9 @@ declare namespace DataCy { "task-detail-keys" | "task-detail-project" | "task-detail-submit" | + "task-detail-user-characters" | + "task-detail-user-keys" | + "task-detail-user-words" | "task-detail-words" | "task-item" | "task-item-detail" | @@ -504,6 +508,7 @@ declare namespace DataCy { "task-preview-words" | "task-select-item" | "task-select-search" | + "task-state" | "tasks-filter-menu" | "tasks-header-add-task" | "tasks-header-filter-select" | diff --git a/webapp/src/component/task/TaskScope.tsx b/webapp/src/component/task/TaskScope.tsx index 0c1ba0a1ce..dec34960ed 100644 --- a/webapp/src/component/task/TaskScope.tsx +++ b/webapp/src/component/task/TaskScope.tsx @@ -78,11 +78,25 @@ export const TaskScope = ({ task, perUserData }: Props) => { /> {item.user.name} - {formatNumber(item.doneItems)} - + + {formatNumber(item.doneItems)} + + {formatNumber(item.baseWordCount)} - + {formatNumber(item.baseCharacterCount)} diff --git a/webapp/src/component/task/TaskState.tsx b/webapp/src/component/task/TaskState.tsx index 7ddbe8a9d4..7e6545dae7 100644 --- a/webapp/src/component/task/TaskState.tsx +++ b/webapp/src/component/task/TaskState.tsx @@ -28,7 +28,7 @@ export const TaskState = ({ state }: Props) => { const stateColor = useStateColor(); return ( - + {translateState(state)} ); diff --git a/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx b/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx index ba3fce3339..2ac6295717 100644 --- a/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx +++ b/webapp/src/component/task/taskCreate/TaskCreateDialog.tsx @@ -47,7 +47,7 @@ const StyledSubtitle = styled('div')` color: ${({ theme }) => theme.palette.text.secondary}; `; -const StyledForm = styled('form')` +const StyledContainer = styled('div')` display: grid; padding: ${({ theme }) => theme.spacing(3)}; gap: ${({ theme }) => theme.spacing(0.5, 3)}; @@ -196,9 +196,9 @@ export const TaskCreateDialog = ({ ); }} > - {({ values, handleSubmit, setFieldValue, submitForm }) => { + {({ values, setFieldValue, submitForm }) => { return ( - + )} - {t('create_task_submit_button')} - + ); }} diff --git a/webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx b/webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx index 874df7ec3a..4af73f6df2 100644 --- a/webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx +++ b/webapp/src/component/task/taskSelect/TaskSearchSelectPopover.tsx @@ -12,7 +12,7 @@ import { import { useTranslate } from '@tolgee/react'; import { useDebounce } from 'use-debounce'; -import { components } from 'tg.service/apiSchema.generated'; +import { components, operations } from 'tg.service/apiSchema.generated'; import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; import { TaskSearchSelectItem } from './TaskSearchSelectItem'; @@ -84,14 +84,11 @@ export const TaskSearchSelectPopover: React.FC = ({ const [search] = useDebounce(inputValue, 500); const query = { - params: { - filterCurrentUserOwner: Boolean(ownedOnly), - search: search || undefined, - filterState: ['IN_PROGRESS '], - }, + search: search || undefined, + filterState: ['IN_PROGRESS', 'NEW'], size: 20, sort: ['name'], - }; + } as const satisfies operations['getTasks_1']['parameters']['query']; const usersLoadable = useApiInfiniteQuery({ url: '/v2/projects/{projectId}/tasks', diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx index d1ef4c31e3..5c413e4501 100644 --- a/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx @@ -11,6 +11,7 @@ import { getTextWidth } from 'tg.fixtures/getTextWidth'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { BatchActions } from './types'; +import { useTranslationsSelector } from '../context/TranslationsContext'; const StyledSeparator = styled('div')` width: 100%; @@ -27,6 +28,9 @@ export const BatchSelect = ({ value, onChange }: Props) => { const theme = useTheme(); const { t } = useTranslate(); const { satisfiesPermission } = useProjectPermissions(); + const prefilteredTask = useTranslationsSelector( + (c) => c.prefilter?.task !== undefined + ); const canEditKey = satisfiesPermission('keys.edit'); const canDeleteKey = satisfiesPermission('keys.delete'); const canMachineTranslate = satisfiesPermission('translations.batch-machine'); @@ -34,12 +38,14 @@ export const BatchSelect = ({ value, onChange }: Props) => { const canChangeState = satisfiesPermission('translations.state-edit'); const canViewTranslations = satisfiesPermission('translations.view'); const canEditTranslations = satisfiesPermission('translations.edit'); + const canEditTasks = satisfiesPermission('tasks.edit'); const options: { id: BatchActions; label: string; divider?: boolean; enabled?: boolean; + hidden?: boolean; }[] = [ { id: 'machine_translate', @@ -80,17 +86,19 @@ export const BatchSelect = ({ value, onChange }: Props) => { id: 'task_create', label: t('batch_operations_create_task'), divider: true, - enabled: canEditKey, + enabled: canEditTasks, }, { id: 'task_add_keys', label: t('batch_operations_task_add_keys'), - enabled: canEditKey, + enabled: canEditTasks, + hidden: prefilteredTask, }, { id: 'task_remove_keys', label: t('batch_operations_task_remove_keys'), - enabled: canEditKey, + enabled: canEditTasks, + hidden: !prefilteredTask, }, { id: 'add_tags', @@ -150,7 +158,7 @@ export const BatchSelect = ({ value, onChange }: Props) => { )} )} - options={options} + options={options.filter((o) => !o.hidden)} renderInput={(params) => { return ( diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx index 5c7dc60620..cf87bab18b 100644 --- a/webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx @@ -16,17 +16,18 @@ export const OperationTaskCreate = ({ disabled, onFinished }: Props) => { const [dialogOpen, setDialogOpen] = useState(true); const allLanguages = useTranslationsSelector((c) => c.languages) ?? []; - const languagesWithoutBase = allLanguages.filter((l) => !l.base); const selection = useTranslationsSelector((c) => c.selection); const translationsLanguages = useTranslationsSelector( (c) => c.translationsLanguages ); const languageAssignees = {} as Record; - getPreselectedLanguagesIds( - languagesWithoutBase, + const selectedLanguages = getPreselectedLanguagesIds( + allLanguages, translationsLanguages ?? [] - ).forEach((langId) => { + ); + + selectedLanguages.forEach((langId) => { languageAssignees[langId] = []; }); @@ -37,12 +38,13 @@ export const OperationTaskCreate = ({ disabled, onFinished }: Props) => { onClick={() => setDialogOpen(true)} /> setDialogOpen(false)} initialValues={{ selection, languageAssignees, - languages: languagesWithoutBase.map((l) => l.id), + languages: selectedLanguages, }} allLanguages={allLanguages} projectId={project.id} diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx index 83c1d4b9b2..61f922fa90 100644 --- a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx @@ -28,7 +28,11 @@ export const BatchProgress = ({ max, progress }: Props) => { const percent = (progress / (max || 1)) * 100; return ( - + ); }; diff --git a/webapp/src/views/projects/translations/context/TranslationsContext.ts b/webapp/src/views/projects/translations/context/TranslationsContext.ts index d19dd1a284..56e41be051 100644 --- a/webapp/src/views/projects/translations/context/TranslationsContext.ts +++ b/webapp/src/views/projects/translations/context/TranslationsContext.ts @@ -105,11 +105,13 @@ export const [ const stateService = useStateService({ translations: translationService, taskService, + prefilter: props.prefilter, }); const editService = useEditService({ translations: translationService, viewRefs, taskService, + prefilter: props.prefilter, }); const tagsService = useTagsService({ diff --git a/webapp/src/views/projects/translations/context/services/useEditService.tsx b/webapp/src/views/projects/translations/context/services/useEditService.tsx index 61ef9880b0..9b65fe6e25 100644 --- a/webapp/src/views/projects/translations/context/services/useEditService.tsx +++ b/webapp/src/views/projects/translations/context/services/useEditService.tsx @@ -33,6 +33,7 @@ import { } from '../types'; import { getPluralVariants } from '@tginternal/editor'; import { useTaskService } from './useTaskService'; +import { PrefilterType } from '../../prefilters/usePrefilter'; /** * Kinda hacky way how to update react-list size cache, when editor gets open @@ -63,6 +64,7 @@ type Props = { translations: ReturnType; viewRefs: ReturnType; taskService: ReturnType; + prefilter: PrefilterType | undefined; }; function generateCurrentValue( @@ -112,6 +114,7 @@ export const useEditService = ({ translations, viewRefs, taskService, + prefilter, }: Props) => { const [position, setPosition] = useState(undefined); const currentIndex = useMemo(() => { @@ -374,7 +377,7 @@ export const useEditService = ({ ]); } - if (language && !data.preventTaskResolution) { + if (language && !data.preventTaskResolution && prefilter?.task) { const key = translations.fixedTranslations?.find( (k) => k.keyId === keyId ); @@ -382,6 +385,7 @@ export const useEditService = ({ if ( task && + prefilter.task === task.number && !task.done && task.userAssigned && task.type === 'TRANSLATE' diff --git a/webapp/src/views/projects/translations/context/services/useStateService.tsx b/webapp/src/views/projects/translations/context/services/useStateService.tsx index 27b65a4ab0..5f6468c97b 100644 --- a/webapp/src/views/projects/translations/context/services/useStateService.tsx +++ b/webapp/src/views/projects/translations/context/services/useStateService.tsx @@ -4,13 +4,19 @@ import { useProject } from 'tg.hooks/useProject'; import { SetTranslationState } from '../types'; import { useTranslationsService } from './useTranslationsService'; import { useTaskService } from './useTaskService'; +import { PrefilterType } from '../../prefilters/usePrefilter'; type Props = { translations: ReturnType; taskService: ReturnType; + prefilter: PrefilterType | undefined; }; -export const useStateService = ({ translations, taskService }: Props) => { +export const useStateService = ({ + translations, + taskService, + prefilter, +}: Props) => { const putTranslationState = usePutTranslationState(); const project = useProject(); @@ -35,6 +41,7 @@ export const useStateService = ({ translations, taskService }: Props) => { if ( data.state === 'REVIEWED' && task?.userAssigned && + prefilter?.task === task?.number && task.type === 'REVIEW' && !task.done ) { From 34bdbb85dd31fa3ba854a294b2f62caf0269a7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 13:25:41 +0200 Subject: [PATCH 07/26] fix: my tasks e2e + fix some bugs --- .../controllers/internal/e2eData/TaskE2eDataController.kt | 5 ++--- webapp/src/views/projects/tasks/ProjectTasksView.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) 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 46e39e2ea7..da7051639e 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 @@ -9,20 +9,19 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController - @RestController @CrossOrigin(origins = ["*"]) @Hidden @RequestMapping(value = ["internal/e2e-data/task"]) @Transactional -class TaskE2eDataController(): AbstractE2eDataController() { - +class TaskE2eDataController() : AbstractE2eDataController() { @GetMapping(value = ["/generate"]) @Transactional fun generateBasicTestData() { val data = TaskTestData() testDataService.saveTestData(data.root) } + override val testData: TestDataBuilder get() = TaskTestData().root } diff --git a/webapp/src/views/projects/tasks/ProjectTasksView.tsx b/webapp/src/views/projects/tasks/ProjectTasksView.tsx index 298dc2722a..ff87d7d2b8 100644 --- a/webapp/src/views/projects/tasks/ProjectTasksView.tsx +++ b/webapp/src/views/projects/tasks/ProjectTasksView.tsx @@ -152,7 +152,7 @@ export const ProjectTasksView = () => { onFinished={() => setAddDialog(false)} initialValues={{ languages: allLanguages - .filter((l) => !l.base && languagesPreference.includes(l.tag)) + .filter((l) => languagesPreference.includes(l.tag)) .map((l) => l.id), }} projectId={project.id} From 479a48567d798090b2bd63ac1bcc22de404a475c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 13:42:54 +0200 Subject: [PATCH 08/26] feat: task indicator in neutral color, displayed for everyone --- e2e/cypress/support/dataCyType.d.ts | 1 + .../translations/cell/ControlsEditorSmall.tsx | 2 +- .../translations/cell/TranslationFlags.tsx | 22 +++++-------------- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index b27bf30438..dee5bfee59 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -581,6 +581,7 @@ declare namespace DataCy { "translations-tag-close" | "translations-tag-input" | "translations-tags-add" | + "translations-task-indicator" | "translations-toolbar-counter" | "translations-toolbar-to-top" | "translations-view-list" | diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx index 573d113f82..07fbb2fb30 100644 --- a/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx @@ -67,7 +67,7 @@ export const ControlsEditorSmall: React.FC = ({ const prefilteredTask = useTranslationsSelector((c) => c.prefilter?.task); const displayTaskButton = - task && task.number === prefilteredTask && task.userAssigned && !task.done; + task && task.number === prefilteredTask && task.userAssigned; const displayEditorMode = project.icuPlaceholders; diff --git a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx index 10d111d4d3..713d05b4dd 100644 --- a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx +++ b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx @@ -10,10 +10,7 @@ import { StyledImgWrapper, TranslationFlagIcon, } from 'tg.component/TranslationFlagIcon'; -import { - useTranslationsActions, - useTranslationsSelector, -} from '../context/TranslationsContext'; +import { useTranslationsActions } from '../context/TranslationsContext'; import { useState } from 'react'; import { TaskDetail } from 'tg.component/task/TaskDetail'; import { stopAndPrevent } from 'tg.fixtures/eventHandler'; @@ -76,9 +73,6 @@ export const TranslationFlags: React.FC = ({ const { t } = useTranslate(); const translation = keyData.translations[lang]; const task = keyData.tasks?.find((t) => t.languageTag === lang); - const prefilteredTask = useTranslationsSelector((c) => c.prefilter?.task); - const displayTaskFlag = - task && (task.number !== prefilteredTask || !task.userAssigned); const { updateTranslation } = useTranslationsActions(); const [taskDetailData, setTaskDetailData] = useState(); @@ -131,25 +125,19 @@ export const TranslationFlags: React.FC = ({ }); }; - if (translation?.auto || translation?.outdated || displayTaskFlag) { + if (translation?.auto || translation?.outdated || task) { return ( - {displayTaskFlag && ( + {task && ( - + From 0c834fb13e970e92446d8f9b023c9384c2ddf0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 13:56:35 +0200 Subject: [PATCH 09/26] feat: task indicator in neutral color, displayed for everyone --- .../views/projects/translations/ToolsPanel/common/Panel.tsx | 6 ++++-- .../views/projects/translations/ToolsPanel/common/types.ts | 1 + .../projects/translations/ToolsPanel/panels/Tasks/Tasks.tsx | 6 +++--- .../views/projects/translations/ToolsPanel/panelsList.tsx | 1 + 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx b/webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx index 0d147b1a5e..016d3a6c45 100644 --- a/webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx @@ -69,6 +69,7 @@ export const Panel = ({ data, itemsCountFunction, hideWhenCountZero, + hideCount, onToggle, open, }: Props) => { @@ -79,7 +80,7 @@ export const Panel = ({ ? itemsCount : itemsCountFunction?.(data) ?? null; - const hidden = !countContent && hideWhenCountZero; + const hidden = countContent === 0 && hideWhenCountZero; if (hidden) { return null; @@ -90,7 +91,8 @@ export const Panel = ({ e.preventDefault()}> {icon} {name} - {typeof itemsCount === 'number' || itemsCountFunction ? ( + {!hideCount && + (typeof itemsCount === 'number' || itemsCountFunction) ? ( {countContent} ) : (
diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/types.ts b/webapp/src/views/projects/translations/ToolsPanel/common/types.ts index a3fdc2e619..521109e13c 100644 --- a/webapp/src/views/projects/translations/ToolsPanel/common/types.ts +++ b/webapp/src/views/projects/translations/ToolsPanel/common/types.ts @@ -30,4 +30,5 @@ export type PanelConfig = { itemsCountFunction?: (props: PanelContentData) => number | React.ReactNode; displayPanel?: (value: PanelContentData) => boolean; hideWhenCountZero?: boolean; + hideCount?: boolean; }; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/Tasks/Tasks.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/Tasks/Tasks.tsx index 64b6139902..04519b5908 100644 --- a/webapp/src/views/projects/translations/ToolsPanel/panels/Tasks/Tasks.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/Tasks/Tasks.tsx @@ -73,7 +73,7 @@ export const Tasks: React.FC = ({ }; export const tasksCount = ({ keyData, language }: PanelContentData) => { - return keyData.tasks?.filter((t) => t.languageId === language.id)?.length ?? 0 - ? '...' - : null; + return ( + keyData.tasks?.filter((t) => t.languageId === language.id)?.length ?? 0 + ); }; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx b/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx index 754079de83..c5804aee4f 100644 --- a/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx @@ -60,5 +60,6 @@ export const PANELS = [ displayPanel: ({ projectPermissions }) => projectPermissions.satisfiesPermission('tasks.view'), hideWhenCountZero: true, + hideCount: true, }, ] satisfies PanelConfig[]; From f01f11c6ef2a79cff1ebba187d89799f4e53072f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 14:09:30 +0200 Subject: [PATCH 10/26] feat: improve layout and font sizes --- webapp/src/component/security/UserMenu/UserPresentMenu.tsx | 1 + webapp/src/component/task/TaskLabel.tsx | 1 + webapp/src/component/task/TaskTooltip.tsx | 2 +- .../translations/TranslationsTable/TranslationsTable.tsx | 2 +- .../views/projects/translations/cell/ControlsEditorMain.tsx | 3 +++ 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/webapp/src/component/security/UserMenu/UserPresentMenu.tsx b/webapp/src/component/security/UserMenu/UserPresentMenu.tsx index c822e90911..a632a4e09d 100644 --- a/webapp/src/component/security/UserMenu/UserPresentMenu.tsx +++ b/webapp/src/component/security/UserMenu/UserPresentMenu.tsx @@ -146,6 +146,7 @@ export const UserPresentMenu: React.FC = () => { { } = useColumns({ tableRef, initialRatios: columns.map(() => 1), - minSize: 300, + minSize: 350, deps: [toolsPanelOpen], }); diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx index 9729d2309b..7ad4b1c1c7 100644 --- a/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx @@ -72,6 +72,9 @@ export const ControlsEditorMain: React.FC = ({ variant="contained" loading={isEditLoading} data-cy="translations-cell-save-button" + sx={{ + whiteSpace: 'nowrap', + }} > From 7c050ba55877c4f3f799ae1bb989df2b52ec193c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 15:14:58 +0200 Subject: [PATCH 11/26] feat: add tooltips and progress green --- webapp/src/component/task/BoardColumn.tsx | 26 ++++++++++--------- webapp/src/component/task/BoardItem.tsx | 21 ++++++++++----- webapp/src/component/task/TaskItem.tsx | 16 +++++++----- webapp/src/component/task/TasksBoard.tsx | 3 +++ webapp/src/views/myTasks/MyTasksView.tsx | 2 +- .../views/projects/tasks/ProjectTasksView.tsx | 2 +- .../OperationsSummary/BatchProgress.tsx | 7 +++++ 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/webapp/src/component/task/BoardColumn.tsx b/webapp/src/component/task/BoardColumn.tsx index f1a73bde51..62c1a17e72 100644 --- a/webapp/src/component/task/BoardColumn.tsx +++ b/webapp/src/component/task/BoardColumn.tsx @@ -1,10 +1,9 @@ -import { Box, Chip, styled, useTheme } from '@mui/material'; +import { Box, Chip, styled } from '@mui/material'; import { useStateColor } from 'tg.component/task/TaskState'; import { components } from 'tg.service/apiSchema.generated'; import { useTaskStateTranslation } from 'tg.translationTools/useTaskStateTranslation'; import { BoardItem } from 'tg.component/task/BoardItem'; import { Scope } from 'tg.fixtures/permissions'; -import { useTranslate } from '@tolgee/react'; type TaskModel = components['schemas']['TaskModel']; type TaskWithProjectModel = components['schemas']['TaskWithProjectModel']; @@ -26,6 +25,16 @@ const StyledColumnTitle = styled(Box)` font-weight: 500; `; +const StyledEmptyMessage = styled(Box)` + display: grid; + height: 138px; + padding: 20px; + align-content: center; + justify-content: center; + font-style: italic; + background: ${({ theme }) => theme.palette.tokens.background.hover}; +`; + type Props = { state?: TaskModel['state']; tasks: (TaskModel | TaskWithProjectModel)[]; @@ -34,6 +43,7 @@ type Props = { projectScopes?: Scope[]; onDetailOpen: (task: TaskModel) => void; title?: React.ReactNode; + emptyMessage: React.ReactNode; }; export const BoardColumn = ({ @@ -44,11 +54,10 @@ export const BoardColumn = ({ projectScopes, onDetailOpen, title, + emptyMessage, }: Props) => { const translateState = useTaskStateTranslation(); const stateColor = useStateColor(); - const { t } = useTranslate(); - const theme = useTheme(); return ( @@ -71,14 +80,7 @@ export const BoardColumn = ({ /> )) ) : ( - - {t('board_column_no_tasks')} - + {emptyMessage} )} ); diff --git a/webapp/src/component/task/BoardItem.tsx b/webapp/src/component/task/BoardItem.tsx index 0b07b7b169..3a1f138818 100644 --- a/webapp/src/component/task/BoardItem.tsx +++ b/webapp/src/component/task/BoardItem.tsx @@ -1,4 +1,4 @@ -import { Box, IconButton, styled } from '@mui/material'; +import { Box, IconButton, styled, Tooltip } from '@mui/material'; import { AlarmClock, DotsVertical } from '@untitled-ui/icons-react'; import { useState } from 'react'; import { TaskLabel } from 'tg.component/task/TaskLabel'; @@ -35,6 +35,11 @@ const Container = styled(Box)` &:focus-within .showOnHover { opacity: 1; } + + &:hover, + &:focus-within { + background: ${({ theme }) => theme.palette.tokens.background.hover}; + } `; const StyledProgress = styled(Box)` @@ -86,12 +91,14 @@ export const BoardItem = ({ style={{ opacity: anchorEl ? 1 : undefined }} onClick={stopAndPrevent()} > - onDetailOpen(task))} - > - - + + onDetailOpen(task))} + > + + + setAnchorEl(e.currentTarget))} diff --git a/webapp/src/component/task/TaskItem.tsx b/webapp/src/component/task/TaskItem.tsx index 9c0564967f..4b2d896e92 100644 --- a/webapp/src/component/task/TaskItem.tsx +++ b/webapp/src/component/task/TaskItem.tsx @@ -129,13 +129,15 @@ export const TaskItem = ({ - onDetailOpen(task))} - data-cy="task-item-detail" - > - - + + onDetailOpen(task))} + data-cy="task-item-detail" + > + + + setAnchorEl(e.currentTarget))} diff --git a/webapp/src/component/task/TasksBoard.tsx b/webapp/src/component/task/TasksBoard.tsx index 72fb79e76a..317f1d26d3 100644 --- a/webapp/src/component/task/TasksBoard.tsx +++ b/webapp/src/component/task/TasksBoard.tsx @@ -82,6 +82,7 @@ export const TasksBoard = ({ total={newTasks.data?.pages?.[0]?.page?.totalElements ?? 0} project={project} onDetailOpen={onOpenDetail} + emptyMessage={t('task_board_empty_new')} /> {canFetchMore && ( { { + theme.palette.tokens._components.progressbar.task.done}; + } `; type Props = { @@ -29,6 +35,7 @@ export const BatchProgress = ({ max, progress }: Props) => { return ( Date: Thu, 19 Sep 2024 15:34:31 +0200 Subject: [PATCH 12/26] feat: assign to me --- .../assigneeSelect/AssigneeSearchSelect.tsx | 6 +++- .../AssigneeSearchSelectPopover.tsx | 28 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx index 62d9e29c50..aa547381fa 100644 --- a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from 'react'; import { ArrowDropDown } from 'tg.component/CustomIcons'; import { XClose } from '@untitled-ui/icons-react'; -import { Box, styled, IconButton, SxProps } from '@mui/material'; +import { Box, styled, IconButton, SxProps, useTheme } from '@mui/material'; import { stopAndPrevent } from 'tg.fixtures/eventHandler'; import { TextField } from 'tg.component/common/TextField'; @@ -37,6 +37,7 @@ export const AssigneeSearchSelect: React.FC = ({ disabled, filters, }) => { + const theme = useTheme(); const anchorEl = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -69,6 +70,9 @@ export const AssigneeSearchSelect: React.FC = ({ minHeight={false} label={label} disabled={disabled} + sx={{ + background: theme.palette.background.default, + }} InputProps={{ onClick: handleClick, disabled: disabled, diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx index cf65f545ca..4f057cc71c 100644 --- a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx @@ -17,11 +17,12 @@ import { operations } from 'tg.service/apiSchema.generated'; import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; import { User, UserAccount } from 'tg.component/UserAccount'; +import { useUser } from 'tg.globalContext/helpers'; export type AssigneeFilters = operations['getPossibleAssignees']['parameters']['query']; -const USERS_SEARCH_TRESHOLD = 5; +const USERS_SEARCH_TRESHOLD = 0; const StyledInput = styled(InputBase)` padding: 5px 4px 3px 16px; @@ -83,6 +84,7 @@ export const AssigneeSearchSelectPopover: React.FC = ({ transformOrigin, filters, }) => { + const currentUser = useUser(); const [inputValue, setInputValue] = useState(''); const { t } = useTranslate(); const [search] = useDebounce(inputValue, 500); @@ -98,13 +100,13 @@ export const AssigneeSearchSelectPopover: React.FC = ({ size: 20, sort: ['name'], ...filters, - }; + } as const satisfies AssigneeFilters; const usersLoadable = useApiInfiniteQuery({ url: '/v2/projects/{projectId}/tasks/possible-assignees', method: 'get', path: { projectId }, - query, + query: query, options: { enabled: open, keepPreviousData: true, @@ -127,9 +129,23 @@ export const AssigneeSearchSelectPopover: React.FC = ({ }, }); - const items: User[] = usersLoadable.data?.pages - .flatMap((page) => page._embedded?.users) - .filter(Boolean) as User[]; + function searchMatches(items: string[]) { + return ( + !search || + items.some((i) => i.toLowerCase().includes(search.toLowerCase())) + ); + } + + const items: User[] = [ + ...(currentUser && + searchMatches([currentUser.username, t('assign_to_me', { noWrap: true })]) + ? [{ id: currentUser.id, username: t('assign_to_me') }] + : []), + ...((usersLoadable.data?.pages + .flatMap((page) => page._embedded?.users) + .filter(Boolean) + .filter((u) => u?.id !== currentUser?.id) as User[]) || []), + ]; const [displaySearch, setDisplaySearch] = useState( undefined From 84b9c80eb2213521af7ea3ba322c95d7e5cabc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 16:08:36 +0200 Subject: [PATCH 13/26] feat: current user name and project with avatar unified --- e2e/cypress/e2e/formerUser.cy.ts | 6 +-- webapp/src/component/ProjectWithAvatar.tsx | 41 +++++++++++++++++++ webapp/src/component/UserAccount.tsx | 5 ++- webapp/src/component/common/UserName.tsx | 33 +++++++++++---- webapp/src/component/task/TaskAssignees.tsx | 7 +++- webapp/src/component/task/TaskDetail.tsx | 3 +- webapp/src/component/task/TaskScope.tsx | 5 ++- .../src/component/task/TaskTooltipContent.tsx | 5 ++- .../assigneeSelect/AssigneeSearchSelect.tsx | 4 +- webapp/src/views/myTasks/MyTasksList.tsx | 3 +- 10 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 webapp/src/component/ProjectWithAvatar.tsx diff --git a/e2e/cypress/e2e/formerUser.cy.ts b/e2e/cypress/e2e/formerUser.cy.ts index 8f99d7287b..11e4496562 100644 --- a/e2e/cypress/e2e/formerUser.cy.ts +++ b/e2e/cypress/e2e/formerUser.cy.ts @@ -26,7 +26,7 @@ describe('Former user', () => { it('shows the former user in activity', () => { cy.visit(`${HOST}/projects/${projectId}`); cy.gcy('project-dashboard-activity-list').should('be.visible'); - cy.gcy('former-user-name').should('be.visible'); + cy.contains('Former user').should('be.visible'); }); it('shows the former user in translation history', () => { @@ -37,7 +37,7 @@ describe('Former user', () => { cy.gcy('translation-history-item') .findDcy('auto-avatar-img') .trigger('mouseover'); - cy.gcy('former-user-name').should('be.visible'); + cy.contains('Former user').should('be.visible'); }); it('shows the former user in translation comments', () => { @@ -45,6 +45,6 @@ describe('Former user', () => { cy.gcy('translations-cell-comments-button').click(); cy.waitForDom(); cy.gcy('comment').findDcy('auto-avatar-img').trigger('mouseover'); - cy.gcy('former-user-name').should('be.visible'); + cy.contains('Former user').should('be.visible'); }); }); diff --git a/webapp/src/component/ProjectWithAvatar.tsx b/webapp/src/component/ProjectWithAvatar.tsx new file mode 100644 index 0000000000..6795f6896c --- /dev/null +++ b/webapp/src/component/ProjectWithAvatar.tsx @@ -0,0 +1,41 @@ +import { Box, styled } from '@mui/material'; +import { components } from 'tg.service/apiSchema.generated'; +import { AvatarImg } from './common/avatar/AvatarImg'; + +export type Avatar = components['schemas']['Avatar']; + +export type Project = { + id: number; + name: string; + avatar?: Avatar; +}; + +const StyledOrgItem = styled('div')` + display: flex; + gap: 8px; + align-items: center; +`; + +type Props = { + project: Project; +}; + +export const ProjectWithAvatar = ({ project }: Props) => { + return ( + + + + + {project.name} + + ); +}; diff --git a/webapp/src/component/UserAccount.tsx b/webapp/src/component/UserAccount.tsx index c93ea98af6..4f781ae281 100644 --- a/webapp/src/component/UserAccount.tsx +++ b/webapp/src/component/UserAccount.tsx @@ -1,6 +1,7 @@ import { Box, styled } from '@mui/material'; import { components } from 'tg.service/apiSchema.generated'; import { AvatarImg } from './common/avatar/AvatarImg'; +import { UserName } from './common/UserName'; export type Avatar = components['schemas']['Avatar']; @@ -36,7 +37,9 @@ export const UserAccount = ({ user }: Props) => { size={22} /> - {user.name || user.username} + + + ); }; diff --git a/webapp/src/component/common/UserName.tsx b/webapp/src/component/common/UserName.tsx index 22387dbebb..ed23bbac38 100644 --- a/webapp/src/component/common/UserName.tsx +++ b/webapp/src/component/common/UserName.tsx @@ -1,20 +1,39 @@ import { styled } from '@mui/material'; -import { T } from '@tolgee/react'; +import { T, useTranslate } from '@tolgee/react'; +import { useUser } from 'tg.globalContext/helpers'; + +export type SimpleUser = { + deleted?: boolean; + username?: string; + name?: string; + id?: number; +}; const StyledFormerUserName = styled('span')` opacity: 0.8; `; -export const UserName = (props: { - deleted?: boolean; - username?: string; - name?: string; -}) => { +export const useUserName = () => { + const currentUser = useUser(); + const { t } = useTranslate(); + return (user: SimpleUser) => { + if (user.deleted) { + return t('former-user-name'); + } else if (user.id === currentUser?.id) { + return t('current_user'); + } else { + return user?.name || user?.username; + } + }; +}; + +export const UserName = (props: SimpleUser) => { + const getUserName = useUserName(); return props?.deleted === true ? ( ) : ( - <>{props?.name || props?.username} + <>{getUserName(props)} ); }; diff --git a/webapp/src/component/task/TaskAssignees.tsx b/webapp/src/component/task/TaskAssignees.tsx index 617a0affe5..4815513747 100644 --- a/webapp/src/component/task/TaskAssignees.tsx +++ b/webapp/src/component/task/TaskAssignees.tsx @@ -1,5 +1,6 @@ import { Box, styled, Tooltip } from '@mui/material'; import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; +import { UserName } from 'tg.component/common/UserName'; import { components } from 'tg.service/apiSchema.generated'; type TaskModel = components['schemas']['TaskModel']; @@ -26,7 +27,11 @@ const renderAssignees = (assignees: SimpleUserAccountModel[]) => { {assignees.map((user) => ( {user.name || user.username}
} + title={ +
+ +
+ } disableInteractive >
diff --git a/webapp/src/component/task/TaskDetail.tsx b/webapp/src/component/task/TaskDetail.tsx index 9b282b21f1..cce62597e0 100644 --- a/webapp/src/component/task/TaskDetail.tsx +++ b/webapp/src/component/task/TaskDetail.tsx @@ -28,6 +28,7 @@ import { TaskScope } from './TaskScope'; import { UserAccount } from 'tg.component/UserAccount'; import { getTaskRedirect, useTaskReport } from './utils'; import { Scope } from 'tg.fixtures/permissions'; +import { ProjectWithAvatar } from 'tg.component/ProjectWithAvatar'; type TaskModel = components['schemas']['TaskModel']; @@ -245,7 +246,7 @@ export const TaskDetail = ({ task, onClose, projectId }: Props) => { /> } data-cy="task-detail-project" /> diff --git a/webapp/src/component/task/TaskScope.tsx b/webapp/src/component/task/TaskScope.tsx index dec34960ed..a51f05cdd4 100644 --- a/webapp/src/component/task/TaskScope.tsx +++ b/webapp/src/component/task/TaskScope.tsx @@ -6,6 +6,7 @@ import { BatchProgress } from 'tg.views/projects/translations/BatchOperations/Op import { TaskState } from './TaskState'; import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; import React from 'react'; +import { UserName } from 'tg.component/common/UserName'; type TaskModel = components['schemas']['TaskModel']; type TaskPerUserReportModel = components['schemas']['TaskPerUserReportModel']; @@ -76,7 +77,9 @@ export const TaskScope = ({ task, perUserData }: Props) => { }} size={24} /> - {item.user.name} + + + {' '} - {(assignees[0].name ?? '') + + {getUserName(assignees[0]) + (assignees.length > 1 ? ` (+${assignees.length - 1})` : '')} ) : ( diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx index aa547381fa..76d8faf8da 100644 --- a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx @@ -11,6 +11,7 @@ import { } from './AssigneeSearchSelectPopover'; import { FakeInput } from 'tg.component/FakeInput'; import { User } from 'tg.component/UserAccount'; +import { useUserName } from 'tg.component/common/UserName'; const StyledClearButton = styled(IconButton)` margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; @@ -40,6 +41,7 @@ export const AssigneeSearchSelect: React.FC = ({ const theme = useTheme(); const anchorEl = useRef(null); const [isOpen, setIsOpen] = useState(false); + const getUserName = useUserName(); const handleClose = () => { setIsOpen(false); @@ -65,7 +67,7 @@ export const AssigneeSearchSelect: React.FC = ({ u.name || u.username).join(', ')} + value={value.map((u) => getUserName(u)).join(', ')} data-cy="assignee-select" minHeight={false} label={label} diff --git a/webapp/src/views/myTasks/MyTasksList.tsx b/webapp/src/views/myTasks/MyTasksList.tsx index 4c52451993..aaf5481264 100644 --- a/webapp/src/views/myTasks/MyTasksList.tsx +++ b/webapp/src/views/myTasks/MyTasksList.tsx @@ -56,7 +56,7 @@ export const MyTasksList = ({ sx: { display: 'grid', gridTemplateColumns: - '1fr minmax(15%, max-content) minmax(25%, max-content) minmax(10%, max-content) auto', + '1fr minmax(15%, max-content) minmax(25%, max-content) minmax(5%, max-content) minmax(10%, max-content) auto', alignItems: 'center', }, } as ListProps @@ -74,6 +74,7 @@ export const MyTasksList = ({ task={task} onDetailOpen={(task) => onOpenDetail(task as TaskWithProjectModel)} project={task.project} + showProject={true} /> )} itemSeparator={() => } From 00e4de1dd0fd2898dab281dd24c68fc2ac4f2b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 16:22:32 +0200 Subject: [PATCH 14/26] feat: increase task items paddings --- webapp/src/component/common/UserName.tsx | 4 ---- webapp/src/component/task/TaskItem.tsx | 2 +- .../task/assigneeSelect/AssigneeSearchSelectPopover.tsx | 5 ++--- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/webapp/src/component/common/UserName.tsx b/webapp/src/component/common/UserName.tsx index ed23bbac38..01f2ab1d95 100644 --- a/webapp/src/component/common/UserName.tsx +++ b/webapp/src/component/common/UserName.tsx @@ -1,6 +1,5 @@ import { styled } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; -import { useUser } from 'tg.globalContext/helpers'; export type SimpleUser = { deleted?: boolean; @@ -14,13 +13,10 @@ const StyledFormerUserName = styled('span')` `; export const useUserName = () => { - const currentUser = useUser(); const { t } = useTranslate(); return (user: SimpleUser) => { if (user.deleted) { return t('former-user-name'); - } else if (user.id === currentUser?.id) { - return t('current_user'); } else { return user?.name || user?.username; } diff --git a/webapp/src/component/task/TaskItem.tsx b/webapp/src/component/task/TaskItem.tsx index 4b2d896e92..d48bd22de6 100644 --- a/webapp/src/component/task/TaskItem.tsx +++ b/webapp/src/component/task/TaskItem.tsx @@ -85,7 +85,7 @@ export const TaskItem = ({ return ( - + = ({ } const items: User[] = [ - ...(currentUser && - searchMatches([currentUser.username, t('assign_to_me', { noWrap: true })]) - ? [{ id: currentUser.id, username: t('assign_to_me') }] + ...(currentUser && searchMatches([currentUser.username]) + ? [{ id: currentUser.id, username: currentUser.username }] : []), ...((usersLoadable.data?.pages .flatMap((page) => page._embedded?.users) From 671c0c89c617e5b2465e605c617a78fe75300369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 17:09:27 +0200 Subject: [PATCH 15/26] feat: task transition translations --- webapp/src/component/task/BoardItem.tsx | 2 +- webapp/src/component/task/TaskItem.tsx | 2 +- .../useTaskTransitionTranslation.ts | 25 +++++++++++++++++++ .../translations/cell/ControlsEditorSmall.tsx | 3 +++ .../translations/cell/ControlsTranslation.tsx | 11 +++++--- .../translations/prefilters/PrefilterTask.tsx | 20 +++++++++++---- 6 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 webapp/src/translationTools/useTaskTransitionTranslation.ts diff --git a/webapp/src/component/task/BoardItem.tsx b/webapp/src/component/task/BoardItem.tsx index 3a1f138818..3804c24340 100644 --- a/webapp/src/component/task/BoardItem.tsx +++ b/webapp/src/component/task/BoardItem.tsx @@ -91,7 +91,7 @@ export const BoardItem = ({ style={{ opacity: anchorEl ? 1 : undefined }} onClick={stopAndPrevent()} > - + onDetailOpen(task))} diff --git a/webapp/src/component/task/TaskItem.tsx b/webapp/src/component/task/TaskItem.tsx index d48bd22de6..ed80d4369b 100644 --- a/webapp/src/component/task/TaskItem.tsx +++ b/webapp/src/component/task/TaskItem.tsx @@ -129,7 +129,7 @@ export const TaskItem = ({ - + onDetailOpen(task))} diff --git a/webapp/src/translationTools/useTaskTransitionTranslation.ts b/webapp/src/translationTools/useTaskTransitionTranslation.ts new file mode 100644 index 0000000000..02804e12f2 --- /dev/null +++ b/webapp/src/translationTools/useTaskTransitionTranslation.ts @@ -0,0 +1,25 @@ +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; + +type TaskType = components['schemas']['TaskModel']['type']; + +export function useTaskTransitionTranslation() { + const { t } = useTranslate(); + + return (type: TaskType, done: boolean) => { + switch (type) { + case 'REVIEW': + if (done) { + return t('task_transition_done_to_review'); + } else { + return t('task_transition_to_review_done'); + } + case 'TRANSLATE': + if (done) { + return t('task_transition_done_to_translate'); + } else { + return t('task_transition_to_translate_done'); + } + } + }; +} diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx index 07fbb2fb30..7b350d8872 100644 --- a/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx @@ -10,6 +10,7 @@ import { StateTransitionButtons } from './StateTransitionButtons'; import { useTranslationsSelector } from '../context/TranslationsContext'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { useProject } from 'tg.hooks/useProject'; +import { useTaskTransitionTranslation } from 'tg.translationTools/useTaskTransitionTranslation'; type State = components['schemas']['TranslationViewModel']['state']; type TaskModel = components['schemas']['KeyTaskViewModel']; @@ -58,6 +59,7 @@ export const ControlsEditorSmall: React.FC = ({ const baseLanguage = useTranslationsSelector((c) => c.languages?.find((l) => l.base) ); + const translateTransition = useTaskTransitionTranslation(); const displayInsertBase = onInsertBase && !isBaseLanguage && @@ -115,6 +117,7 @@ export const ControlsEditorSmall: React.FC = ({ data-cy="translations-cell-task-button" onClick={() => onTaskStateChange(!task?.done)} color={task?.done ? 'secondary' : 'primary'} + tooltip={translateTransition(task.type, task.done)} > diff --git a/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx b/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx index 5d82dab35e..2be3571898 100644 --- a/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx @@ -7,7 +7,7 @@ import { Edit02, ClipboardCheck, } from '@untitled-ui/icons-react'; -import { T } from '@tolgee/react'; +import { useTranslate } from '@tolgee/react'; import { StateInType } from 'tg.constants/translationStates'; import { components } from 'tg.service/apiSchema.generated'; @@ -15,6 +15,7 @@ import { ControlsButton } from './ControlsButton'; import { StateTransitionButtons } from './StateTransitionButtons'; import { CELL_HIGHLIGHT_ON_HOVER, CELL_SHOW_ON_HOVER } from './styles'; import { useTranslationsSelector } from '../context/TranslationsContext'; +import { useTaskTransitionTranslation } from 'tg.translationTools/useTaskTransitionTranslation'; type State = components['schemas']['TranslationViewModel']['state']; type TaskModel = components['schemas']['KeyTaskViewModel']; @@ -95,6 +96,7 @@ export const ControlsTranslation: React.FC = ({ }) => { const spots: string[] = []; + const translateTransition = useTaskTransitionTranslation(); const displayTransitionButtons = stateChangeEnabled && state; const displayEdit = editEnabled && onEdit; const commentsPresent = Boolean(commentsCount); @@ -128,6 +130,8 @@ export const ControlsTranslation: React.FC = ({ .map((spot) => (spot === 'state' ? 'auto' : '28px')) .join(' '); + const { t } = useTranslate(); + return ( = ({ onClick={onEdit} data-cy="translations-cell-edit-button" className={CELL_SHOW_ON_HOVER} - tooltip={} + tooltip={t('translations_cell_edit')} > @@ -165,7 +169,7 @@ export const ControlsTranslation: React.FC = ({ [CELL_SHOW_ON_HOVER]: !commentsPresent, [CELL_HIGHLIGHT_ON_HOVER]: onlyResolved, })} - tooltip={} + tooltip={t('translation_cell_comments')} > {onlyResolved ? ( = ({ : 'primary' : undefined } + tooltip={translateTransition(task.type, task.done)} > diff --git a/webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx b/webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx index 216fb4b8e5..0044ba7306 100644 --- a/webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx +++ b/webapp/src/views/projects/translations/prefilters/PrefilterTask.tsx @@ -1,6 +1,13 @@ import React, { useState } from 'react'; -import { T } from '@tolgee/react'; -import { Box, Dialog, IconButton, styled, useTheme } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { + Box, + Dialog, + IconButton, + styled, + Tooltip, + useTheme, +} from '@mui/material'; import { AlertTriangle } from '@untitled-ui/icons-react'; import { useApiQuery } from 'tg.service/http/useQueryApi'; @@ -34,6 +41,7 @@ export const PrefilterTask = ({ taskNumber }: Props) => { const project = useProject(); const theme = useTheme(); const [showDetails, setShowDetails] = useState(false); + const { t } = useTranslate(); const { data } = useApiQuery({ url: '/v2/projects/{projectId}/tasks/{taskNumber}', @@ -64,9 +72,11 @@ export const PrefilterTask = ({ taskNumber }: Props) => { content={ - - - + + + + + {blockingTasksLoadable.data?.length ? ( Date: Thu, 19 Sep 2024 17:19:30 +0200 Subject: [PATCH 16/26] feat: assignee placeholder --- .../src/component/task/assigneeSelect/AssigneeSearchSelect.tsx | 3 +++ .../task/assigneeSelect/AssigneeSearchSelectPopover.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx index 76d8faf8da..bf562fbd59 100644 --- a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelect.tsx @@ -12,6 +12,7 @@ import { import { FakeInput } from 'tg.component/FakeInput'; import { User } from 'tg.component/UserAccount'; import { useUserName } from 'tg.component/common/UserName'; +import { useTranslate } from '@tolgee/react'; const StyledClearButton = styled(IconButton)` margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; @@ -42,6 +43,7 @@ export const AssigneeSearchSelect: React.FC = ({ const anchorEl = useRef(null); const [isOpen, setIsOpen] = useState(false); const getUserName = useUserName(); + const { t } = useTranslate(); const handleClose = () => { setIsOpen(false); @@ -76,6 +78,7 @@ export const AssigneeSearchSelect: React.FC = ({ background: theme.palette.background.default, }} InputProps={{ + placeholder: t('assignee_select_unassigned'), onClick: handleClick, disabled: disabled, ref: anchorEl, diff --git a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx index 97048f2ff4..338723aaff 100644 --- a/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx +++ b/webapp/src/component/task/assigneeSelect/AssigneeSearchSelectPopover.tsx @@ -22,7 +22,7 @@ import { useUser } from 'tg.globalContext/helpers'; export type AssigneeFilters = operations['getPossibleAssignees']['parameters']['query']; -const USERS_SEARCH_TRESHOLD = 0; +const USERS_SEARCH_TRESHOLD = 5; const StyledInput = styled(InputBase)` padding: 5px 4px 3px 16px; From 564850581a6f6af21c97841bfe9675a9734dcbb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Thu, 19 Sep 2024 18:03:40 +0200 Subject: [PATCH 17/26] feat: update dashboard --- .../v2/controllers/ProjectStatsController.kt | 6 ++ .../project/stats/ProjectStatsModel.kt | 1 + .../views/projectStats/ProjectStatsView.kt | 1 + .../queryBuilders/ProjectStatsProvider.kt | 13 ++- webapp/src/service/apiSchema.generated.ts | 102 ++++++++++-------- .../views/projects/dashboard/ActivityList.tsx | 2 +- .../dashboard/LanguageStats/LanguageStats.tsx | 34 +++++- .../projects/dashboard/ProjectTotals.tsx | 21 ++-- 8 files changed, 118 insertions(+), 62 deletions(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt index e0b5f3a9bc..87db17154b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt @@ -6,17 +6,21 @@ package io.tolgee.api.v2.controllers import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.dtos.request.task.TaskFilters import io.tolgee.hateoas.project.stats.LanguageStatsModelAssembler import io.tolgee.hateoas.project.stats.ProjectStatsModel import io.tolgee.model.enums.Scope +import io.tolgee.model.enums.TaskState import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.TaskService import io.tolgee.service.language.LanguageService import io.tolgee.service.project.LanguageStatsService import io.tolgee.service.project.ProjectService import io.tolgee.service.project.ProjectStatsService +import org.springframework.data.domain.Pageable import org.springframework.hateoas.MediaTypes import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping @@ -36,6 +40,7 @@ class ProjectStatsController( private val languageStatsService: LanguageStatsService, private val languageStatsModelAssembler: LanguageStatsModelAssembler, private val languageService: LanguageService, + private val taskService: TaskService, ) { @Operation(summary = "Get project stats") @GetMapping("", produces = [MediaTypes.HAL_JSON_VALUE]) @@ -62,6 +67,7 @@ class ProjectStatsController( projectId = projectStats.id, languageCount = languageStats.size, keyCount = projectStats.keyCount, + taskCount = projectStats.taskCount, baseWordsCount = totals.baseWordsCount, translatedPercentage = totals.translatedPercent, reviewedPercentage = totals.reviewedPercent, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt index 836372fe4e..c1958fdabb 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt @@ -5,6 +5,7 @@ open class ProjectStatsModel( val projectId: Long, val languageCount: Int, val keyCount: Long, + val taskCount: Long, val baseWordsCount: Long, val translatedPercentage: Double, val reviewedPercentage: Double, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt index 2be32f71a5..09a3bbdf06 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt @@ -5,4 +5,5 @@ data class ProjectStatsView( val keyCount: Long, val memberCount: Long, val tagCount: Long, + val taskCount: Long ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt index b470c2ba4a..20e410acdd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt @@ -7,10 +7,13 @@ import io.tolgee.model.Project import io.tolgee.model.Project_ import io.tolgee.model.UserAccount import io.tolgee.model.UserAccount_ +import io.tolgee.model.enums.TaskState import io.tolgee.model.key.Key import io.tolgee.model.key.Key_ import io.tolgee.model.key.Tag import io.tolgee.model.key.Tag_ +import io.tolgee.model.task.Task +import io.tolgee.model.task.Task_ import io.tolgee.model.views.projectStats.ProjectStatsView import io.tolgee.util.KotlinCriteriaBuilder import jakarta.persistence.EntityManager @@ -36,6 +39,7 @@ open class ProjectStatsProvider( getKeyCountSelection(), getMemberCountSelection(), getTagSelection(), + getTaskCountSelection() ) query.multiselect(selection) @@ -66,7 +70,6 @@ open class ProjectStatsProvider( val permissionJoin = subProject.join(Project_.permissions, JoinType.LEFT) val organizationJoin = subProject.join(Project_.organizationOwner, JoinType.LEFT) val rolesJoin = organizationJoin.join(Organization_.memberRoles, JoinType.LEFT) - sub.where( project equal subProject and ( @@ -76,4 +79,12 @@ open class ProjectStatsProvider( ) return sub.select(cb.countDistinct(subUserAccount.get(UserAccount_.id))) } + + private fun getTaskCountSelection(): Selection { + val sub = query.subquery(Long::class.java) + val task = sub.from(Task::class.java) + sub.where(task.get(Task_.project) equal project) + sub.where(task.get(Task_.state).`in`(listOf(TaskState.NEW, TaskState.IN_PROGRESS))) + return sub.select(cb.coalesce(cb.countDistinct(task.get(Tag_.id)), 0)) + } } diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index fcc9317be3..ef86d3341e 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1085,6 +1085,10 @@ export interface components { | "slack_workspace_already_connected" | "slack_connection_error" | "email_verification_code_not_valid" + | "cannot_subscribe_to_free_plan" + | "plan_auto_assignment_only_for_free_plans" + | "plan_auto_assignment_only_for_private_plans" + | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" | "task_not_found" | "task_not_finished" | "task_not_open"; @@ -1160,21 +1164,11 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; /** * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 */ viewLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1209,6 +1203,16 @@ export interface components { | "tasks.view" | "tasks.edit" )[]; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; /** * @deprecated * @description Deprecated (use translateLanguageIds). @@ -2071,12 +2075,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { + /** @description If false, only updates keys, skipping the creation of new keys */ + createNewKeys: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; - /** @description If false, only updates keys, skipping the creation of new keys */ - createNewKeys: boolean; }; TranslationCommentModel: { /** @@ -2233,17 +2237,17 @@ export interface components { }; RevealedPatModel: { token: string; - description: string; /** Format: int64 */ id: number; /** Format: int64 */ - expiresAt?: number; - /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; + description: string; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2379,19 +2383,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - description: string; /** Format: int64 */ id: number; - /** Format: int64 */ - projectId: number; + userFullName?: string; + projectName: string; + username?: string; /** Format: int64 */ expiresAt?: number; + scopes: string[]; + /** Format: int64 */ + projectId: number; /** Format: int64 */ lastUsedAt?: number; - username?: string; - scopes: string[]; - projectName: string; - userFullName?: string; + description: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2466,7 +2470,6 @@ export interface components { languageId: number; assignees: number[]; keys: number[]; - state?: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; }; CalculateScopeRequest: { /** Format: int64 */ @@ -2476,11 +2479,11 @@ export interface components { }; KeysScopeView: { /** Format: int64 */ - wordCount: number; + characterCount: number; /** Format: int64 */ keyCount: number; /** Format: int64 */ - characterCount: number; + wordCount: number; }; GetKeysRequestDto: { keys: components["schemas"]["KeyDefinitionDto"][]; @@ -2818,6 +2821,10 @@ export interface components { | "slack_workspace_already_connected" | "slack_connection_error" | "email_verification_code_not_valid" + | "cannot_subscribe_to_free_plan" + | "plan_auto_assignment_only_for_free_plans" + | "plan_auto_assignment_only_for_private_plans" + | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" | "task_not_found" | "task_not_finished" | "task_not_open"; @@ -3185,6 +3192,7 @@ export interface components { isPlural?: boolean; /** @description List of services to use. If null, then all enabled services are used. */ services?: ("GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE")[]; + plural?: boolean; }; PagedModelTranslationMemoryItemModel: { _embedded?: { @@ -3537,8 +3545,6 @@ export interface components { | "SLACK_INTEGRATION" )[]; quickStart?: components["schemas"]["QuickStartModel"]; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ @@ -3553,6 +3559,8 @@ export interface components { avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3613,9 +3621,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { - description?: string; name: string; displayName?: string; + description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3710,23 +3718,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { - description?: string; name: string; /** Format: int64 */ id: number; baseTranslation?: string; - translation?: string; namespace?: string; + description?: string; + translation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; - description?: string; name: string; /** Format: int64 */ id: number; baseTranslation?: string; - translation?: string; namespace?: string; + description?: string; + translation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4205,6 +4213,8 @@ export interface components { /** Format: int64 */ keyCount: number; /** Format: int64 */ + taskCount: number; + /** Format: int64 */ baseWordsCount: number; /** Format: double */ translatedPercentage: number; @@ -4290,17 +4300,17 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - description: string; /** Format: int64 */ id: number; /** Format: int64 */ - expiresAt?: number; - /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; + description: string; }; PagedModelOrganizationModel: { _embedded?: { @@ -4417,19 +4427,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - description: string; /** Format: int64 */ id: number; - /** Format: int64 */ - projectId: number; + userFullName?: string; + projectName: string; + username?: string; /** Format: int64 */ expiresAt?: number; + scopes: string[]; + /** Format: int64 */ + projectId: number; /** Format: int64 */ lastUsedAt?: number; - username?: string; - scopes: string[]; - projectName: string; - userFullName?: string; + description: string; }; PagedModelUserAccountModel: { _embedded?: { diff --git a/webapp/src/views/projects/dashboard/ActivityList.tsx b/webapp/src/views/projects/dashboard/ActivityList.tsx index 6fd59f2c2e..d4e13a96d0 100644 --- a/webapp/src/views/projects/dashboard/ActivityList.tsx +++ b/webapp/src/views/projects/dashboard/ActivityList.tsx @@ -93,7 +93,7 @@ export const ActivityList: React.FC = ({ activityLoadable }) => { return ( - + = ({ languageStats, wordCount }) => { + const theme = useTheme(); const languages = useProjectLanguages(); const { satisfiesLanguageAccess, satisfiesPermission } = useProjectPermissions(); @@ -95,6 +106,7 @@ export const LanguageStats: FC = ({ languageStats, wordCount }) => { const baseLanguage = languages.find((l) => l.base === true)!.tag; const allLangs = languages.map((l) => l.tag); const canViewLanguages = satisfiesPermission('translations.view'); + const canEditLanguages = satisfiesPermission('languages.edit'); const redirectToLanguage = (lang?: string) => { const langs = !lang @@ -111,6 +123,26 @@ export const LanguageStats: FC = ({ languageStats, wordCount }) => { return ( + + + {t('dashboard_languages_title')} + + + {canEditLanguages && ( + + + + )} + {languageStats.map((item, i) => { const language = languages.find((l) => l.id === item.languageId)!; const canViewLanguage = satisfiesLanguageAccess( diff --git a/webapp/src/views/projects/dashboard/ProjectTotals.tsx b/webapp/src/views/projects/dashboard/ProjectTotals.tsx index 9666153db6..07963e6827 100644 --- a/webapp/src/views/projects/dashboard/ProjectTotals.tsx +++ b/webapp/src/views/projects/dashboard/ProjectTotals.tsx @@ -146,9 +146,9 @@ export const ProjectTotals: React.FC<{ setAnchorEl(null); }; - const redirectToLanguages = () => { + const redirectToTasks = () => { history.push( - LINKS.PROJECT_LANGUAGES.build({ [PARAMS.PROJECT_ID]: project.id }) + LINKS.PROJECT_TASKS.build({ [PARAMS.PROJECT_ID]: project.id }) ); }; @@ -182,9 +182,9 @@ export const ProjectTotals: React.FC<{ const { satisfiesPermission } = useProjectPermissions(); const canViewMembers = satisfiesPermission('members.view'); - const canEditLanguages = satisfiesPermission('languages.edit'); const canViewKeys = satisfiesPermission('keys.view'); const canEditMembers = satisfiesPermission('members.edit'); + const canViewTasks = satisfiesPermission('tasks.view'); const tagsPresent = Boolean(stats.tagCount); const tagsClickable = tagsPresent && canViewKeys; @@ -201,25 +201,20 @@ export const ProjectTotals: React.FC<{ - {Number(stats.languageCount).toLocaleString(locale)} + {Number(stats.taskCount).toLocaleString(locale)} - {t('project_dashboard_language_count', 'Languages', { - count: stats.languageCount, + {t('project_dashboard_task_count', { + count: stats.taskCount, })} - {canEditLanguages && ( - - - - )} Date: Fri, 20 Sep 2024 09:43:44 +0200 Subject: [PATCH 18/26] feat: update dashboard --- .../io/tolgee/api/v2/controllers/ProjectStatsController.kt | 3 --- .../io/tolgee/model/views/projectStats/ProjectStatsView.kt | 2 +- .../io/tolgee/service/queryBuilders/ProjectStatsProvider.kt | 2 +- .../views/projects/dashboard/LanguageStats/LanguageStats.tsx | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt index 87db17154b..6d1ac4dbba 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt @@ -6,11 +6,9 @@ package io.tolgee.api.v2.controllers import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag -import io.tolgee.dtos.request.task.TaskFilters import io.tolgee.hateoas.project.stats.LanguageStatsModelAssembler import io.tolgee.hateoas.project.stats.ProjectStatsModel import io.tolgee.model.enums.Scope -import io.tolgee.model.enums.TaskState import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authorization.RequiresProjectPermissions @@ -20,7 +18,6 @@ import io.tolgee.service.language.LanguageService import io.tolgee.service.project.LanguageStatsService import io.tolgee.service.project.ProjectService import io.tolgee.service.project.ProjectStatsService -import org.springframework.data.domain.Pageable import org.springframework.hateoas.MediaTypes import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt index 09a3bbdf06..8767edd26b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt @@ -5,5 +5,5 @@ data class ProjectStatsView( val keyCount: Long, val memberCount: Long, val tagCount: Long, - val taskCount: Long + val taskCount: Long, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt index 20e410acdd..ddaf344c29 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt @@ -39,7 +39,7 @@ open class ProjectStatsProvider( getKeyCountSelection(), getMemberCountSelection(), getTagSelection(), - getTaskCountSelection() + getTaskCountSelection(), ) query.multiselect(selection) diff --git a/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx b/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx index d72a2ef85d..8c8df33d1e 100644 --- a/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx +++ b/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx @@ -27,7 +27,7 @@ import clsx from 'clsx'; const StyledContainer = styled('div')` display: grid; grid-template-columns: auto auto auto 10fr auto; - margin: ${({ theme }) => theme.spacing(2, 0)}; + margin: ${({ theme }) => theme.spacing(1, 0, 2, 0)}; `; const StyledRow = styled('div')` From fea46df4e41370c5cc69348fd380d779c06d3073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Fri, 20 Sep 2024 10:56:07 +0200 Subject: [PATCH 19/26] chore: fix e2e tests --- .../testDataBuilder/data/ProjectsTestData.kt | 16 ++++++++++++++++ .../queryBuilders/ProjectStatsProvider.kt | 6 ++++-- e2e/cypress/common/permissions/dashboard.ts | 8 ++++---- e2e/cypress/e2e/projects/projectDashboard.cy.ts | 1 + e2e/cypress/support/dataCyType.d.ts | 2 ++ .../dashboard/LanguageStats/LanguageStats.tsx | 2 ++ .../views/projects/dashboard/ProjectTotals.tsx | 2 +- 7 files changed, 30 insertions(+), 7 deletions(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt index bd6813e37f..a84512d039 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt @@ -4,13 +4,17 @@ import io.tolgee.model.Language import io.tolgee.model.Project import io.tolgee.model.UserAccount import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType import io.tolgee.model.enums.TranslationState +import io.tolgee.model.task.Task class ProjectsTestData : BaseTestData() { lateinit var project2English: Language lateinit var project2Deutsch: Language lateinit var project2: Project lateinit var userWithTranslatePermission: UserAccount + lateinit var task: Task init { root.apply { @@ -53,9 +57,21 @@ class ProjectsTestData : BaseTestData() { translateLanguages = mutableSetOf(project2Deutsch, project2English) } + task = + addTask { + number = 1 + name = "Translate task" + type = TaskType.TRANSLATE + state = TaskState.NEW + project = project2 + language = englishLanguage + author = userWithTranslatePermission + }.self + addKey { name = "Untranslated" } + addKey { name = "Translated to both" }.build keyBuilder@{ diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt index ddaf344c29..240cdaad95 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt @@ -83,8 +83,10 @@ open class ProjectStatsProvider( private fun getTaskCountSelection(): Selection { val sub = query.subquery(Long::class.java) val task = sub.from(Task::class.java) - sub.where(task.get(Task_.project) equal project) - sub.where(task.get(Task_.state).`in`(listOf(TaskState.NEW, TaskState.IN_PROGRESS))) + sub.where( + task.get(Task_.project) equal project + and task.get(Task_.state).`in`(listOf(TaskState.NEW, TaskState.IN_PROGRESS)), + ) return sub.select(cb.coalesce(cb.countDistinct(task.get(Tag_.id)), 0)) } } diff --git a/e2e/cypress/common/permissions/dashboard.ts b/e2e/cypress/common/permissions/dashboard.ts index 4a57baf9f1..bcf345b267 100644 --- a/e2e/cypress/common/permissions/dashboard.ts +++ b/e2e/cypress/common/permissions/dashboard.ts @@ -14,13 +14,13 @@ function tryClickable(item: DataCy.Value, clickable: boolean) { export function testDashboard(projectInfo: ProjectInfo) { const scopes = projectInfo.project.computedPermission.scopes; - tryClickable( - 'project-dashboard-language-count', - scopes.includes('languages.edit') - ); + tryClickable('project-dashboard-task-count', scopes.includes('tasks.view')); tryClickable('project-dashboard-text', scopes.includes('keys.view')); tryClickable('project-dashboard-progress', scopes.includes('keys.view')); tryClickable('project-dashboard-members', scopes.includes('members.view')); + if (scopes.includes('languages.edit')) { + tryClickable('project-dashboard-languages-edit', true); + } if (scopes.includes('activity.view')) { cy.gcy('project-dashboard-activity-list').should('be.visible'); diff --git a/e2e/cypress/e2e/projects/projectDashboard.cy.ts b/e2e/cypress/e2e/projects/projectDashboard.cy.ts index 5128e817d8..c68d96ab70 100644 --- a/e2e/cypress/e2e/projects/projectDashboard.cy.ts +++ b/e2e/cypress/e2e/projects/projectDashboard.cy.ts @@ -66,6 +66,7 @@ describe('Project stats', () => { enterProject('Project 2'); createTag('test_tag'); selectInProjectMenu('Project Dashboard'); + cy.gcy('project-dashboard-task-count').contains(1).should('be.visible'); cy.gcy('project-dashboard-language-count') .contains('2') .should('be.visible'); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index dee5bfee59..f4f419d720 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -382,6 +382,7 @@ declare namespace DataCy { "project-dashboard-language-menu" | "project-dashboard-language-menu-export" | "project-dashboard-language-menu-settings" | + "project-dashboard-languages-edit" | "project-dashboard-members" | "project-dashboard-members-count" | "project-dashboard-progress" | @@ -390,6 +391,7 @@ declare namespace DataCy { "project-dashboard-strings" | "project-dashboard-strings-count" | "project-dashboard-tags" | + "project-dashboard-task-count" | "project-dashboard-text" | "project-dashboard-translated-percentage" | "project-delete-button" | diff --git a/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx b/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx index 8c8df33d1e..5b49f5e53a 100644 --- a/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx +++ b/webapp/src/views/projects/dashboard/LanguageStats/LanguageStats.tsx @@ -127,6 +127,7 @@ export const LanguageStats: FC = ({ languageStats, wordCount }) => { {t('dashboard_languages_title')} = ({ languageStats, wordCount }) => { {canEditLanguages && ( From ec4b31c58ea67a8fd12ec68be784975391e4af26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Fri, 20 Sep 2024 15:33:26 +0200 Subject: [PATCH 20/26] chore: add tasks permission tests --- .../data/PermissionsTestData.kt | 57 ++++++++++++++++++- .../io/tolgee/repository/TaskRepository.kt | 3 +- .../e2eData/PermissionsE2eDataController.kt | 16 +++--- e2e/cypress/common/permissions/keys.ts | 3 +- e2e/cypress/common/permissions/main.ts | 4 +- e2e/cypress/common/permissions/myTasks.ts | 19 +++++++ .../common/permissions/translations.ts | 36 +++++++++++- e2e/cypress/support/dataCyType.d.ts | 2 + .../security/UserMenu/UserPresentMenu.tsx | 1 + .../src/component/task/TaskTooltipContent.tsx | 1 + .../translations/cell/TranslationFlags.tsx | 29 +++++++--- 11 files changed, 147 insertions(+), 24 deletions(-) create mode 100644 e2e/cypress/common/permissions/myTasks.ts diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt index 6df0f699d5..8b0ed53ab1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt @@ -6,16 +6,20 @@ import io.tolgee.development.testDataBuilder.builders.ProjectBuilder import io.tolgee.development.testDataBuilder.builders.TestDataBuilder import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Language +import io.tolgee.model.Project import io.tolgee.model.UserAccount -import io.tolgee.model.enums.OrganizationRoleType -import io.tolgee.model.enums.ProjectPermissionType -import io.tolgee.model.enums.Scope +import io.tolgee.model.enums.* +import io.tolgee.model.key.Key import org.springframework.core.io.ClassPathResource class PermissionsTestData { var projectBuilder: ProjectBuilder var organizationBuilder: OrganizationBuilder var admin: UserAccountBuilder + lateinit var addedProject: Project + lateinit var englishLanguage: Language + lateinit var keys: List val root: TestDataBuilder = TestDataBuilder().apply { @@ -40,6 +44,10 @@ class PermissionsTestData { val de = addGerman() val cs = addCzech() + englishLanguage = en.self + + addedProject = this.self + addPermission { this.user = member.self this.type = ProjectPermissionType.VIEW @@ -63,6 +71,8 @@ class PermissionsTestData { } } + keys = keyBuilders.map { it.self } + keyBuilders[0].apply { val screenshotResource = ClassPathResource("development/testScreenshot.png", this::class.java.getClassLoader()) @@ -127,6 +137,47 @@ class PermissionsTestData { } } + fun addTasks(assignees: MutableSet) { + projectBuilder.apply { + val translateTask = + addTask { + number = 1 + name = "Assigned translate task" + type = TaskType.TRANSLATE + state = TaskState.NEW + project = addedProject + language = englishLanguage + this.assignees = assignees + author = admin.self + }.self + + keys.take(1).forEach { + addTaskKey { + task = translateTask + key = it + } + } + + val reviewTask = + addTask { + number = 2 + name = "Unassigned review task" + type = TaskType.REVIEW + state = TaskState.NEW + project = addedProject + language = englishLanguage + author = admin.self + }.self + + keys.take(2).forEach { + addTaskKey { + task = reviewTask + key = it + } + } + } + } + private fun getLanguagesByTags(tags: List?) = tags?.map { tag -> projectBuilder.data.languages.find { it.self.tag == tag }?.self ?: throw NotFoundException( diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt index 8928008bfb..c6e238e8b0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/TaskRepository.kt @@ -133,11 +133,10 @@ interface TaskRepository : JpaRepository { from task t join task_key tt on (t.id = tt.task_id) left join task_assignees ta on (ta.tasks_id = t.id) - left join user_account u on ta.assignees_id = u.id + left join user_account u on ta.assignees_id = u.id and u.id = :currentUserId left join language l on (t.language_id = l.id) where tt.key_id in :keyIds - and u.id = :currentUserId 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 diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt index b52c0d2e37..3a8469842f 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt @@ -29,13 +29,15 @@ class PermissionsE2eDataController() : AbstractE2eDataController() { @RequestParam translateLanguageTags: List?, @RequestParam stateChangeLanguageTags: List?, ): StandardTestDataResult { - this.permissionsTestData.addUserWithPermissions( - scopes = Scope.parse(scopes).toList(), - type = type, - viewLanguageTags = viewLanguageTags, - translateLanguageTags = translateLanguageTags, - stateChangeLanguageTags = stateChangeLanguageTags, - ) + val user = + this.permissionsTestData.addUserWithPermissions( + scopes = Scope.parse(scopes).toList(), + type = type, + viewLanguageTags = viewLanguageTags, + translateLanguageTags = translateLanguageTags, + stateChangeLanguageTags = stateChangeLanguageTags, + ) + this.permissionsTestData.addTasks(mutableSetOf(user)) return generate() } diff --git a/e2e/cypress/common/permissions/keys.ts b/e2e/cypress/common/permissions/keys.ts index 1033fa2587..1c98bffed7 100644 --- a/e2e/cypress/common/permissions/keys.ts +++ b/e2e/cypress/common/permissions/keys.ts @@ -2,6 +2,7 @@ import { satisfiesLanguageAccess } from '../../../../webapp/src/fixtures/permiss import { deleteSelected } from '../batchOperations'; import { waitForGlobalLoading } from '../loading'; import { confirmStandard, dismissMenu } from '../shared'; +import { getCell } from '../state'; import { createTag } from '../tags'; import { createTranslation, editCell } from '../translations'; import { getLanguageId, getLanguages, ProjectInfo } from './shared'; @@ -56,7 +57,7 @@ export function testKeys(info: ProjectInfo) { !scopes.includes('translations.edit') && scopes.includes('translations.view') ) { - cy.gcy('translations-table-cell-translation').first().click(); + getCell('Czech text 1').click(); cy.gcy('global-editor').should('not.exist'); } diff --git a/e2e/cypress/common/permissions/main.ts b/e2e/cypress/common/permissions/main.ts index f26709a60d..10a2a217d5 100644 --- a/e2e/cypress/common/permissions/main.ts +++ b/e2e/cypress/common/permissions/main.ts @@ -12,6 +12,7 @@ import { testExport } from './export'; import { testIntegration } from './integration'; import { testKeys } from './keys'; import { testMembers } from './members'; +import { testMyTasks } from './myTasks'; import { getProjectInfo, pageAcessibleWithoutErrors, @@ -55,8 +56,9 @@ export function checkPermissions(projectInfo: ProjectInfo, settings: Settings) { testDashboard(projectInfo); break; case 'project-menu-item-translations': - testKeys(projectInfo); + testMyTasks(projectInfo); testTranslations(projectInfo); + testKeys(projectInfo); testBatchOperations(projectInfo); break; case 'project-menu-item-members': diff --git a/e2e/cypress/common/permissions/myTasks.ts b/e2e/cypress/common/permissions/myTasks.ts new file mode 100644 index 0000000000..a0e830ad1a --- /dev/null +++ b/e2e/cypress/common/permissions/myTasks.ts @@ -0,0 +1,19 @@ +import { dismissMenu } from '../shared'; +import { visitTranslations } from '../translations'; +import { pageAcessibleWithoutErrors, ProjectInfo } from './shared'; + +export function testMyTasks(projectInfo: ProjectInfo) { + cy.gcy('global-user-menu-button').click(); + cy.gcy('user-menu-my-tasks').click(); + cy.gcy('task-item-detail').click(); + pageAcessibleWithoutErrors(); + + const scopes = projectInfo.project.computedPermission.scopes; + if (scopes.includes('tasks.edit')) { + cy.gcy('task-detail-field-name').get('input').should('be.enabled'); + } else { + cy.gcy('task-detail-field-name').get('input').should('be.disabled'); + } + dismissMenu(); + visitTranslations(projectInfo.project.id); +} diff --git a/e2e/cypress/common/permissions/translations.ts b/e2e/cypress/common/permissions/translations.ts index 15486845e1..743f50464a 100644 --- a/e2e/cypress/common/permissions/translations.ts +++ b/e2e/cypress/common/permissions/translations.ts @@ -31,6 +31,38 @@ export function testTranslations({ project, languages }: ProjectInfo) { } if (scopes.includes('translations.view')) { + // test task indicator + cy.waitForDom(); + const english1 = getCell('English text 1'); + english1 + .findDcy('translations-task-indicator') + .should('be.visible') + .trigger('mouseover'); + cy.gcy('task-tooltip-content') + .contains('Assigned translate task') + .should('be.visible'); + getCell('English text 1') + .findDcy('translations-task-indicator') + .trigger('mouseout'); + + const english2 = getCell('English text 2'); + english2 + .findDcy('translations-task-indicator') + .should('be.visible') + .trigger('mouseover'); + 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 2') + .findDcy('translations-task-indicator') + .trigger('mouseout'); + getRowTexts(1).forEach(([lang, text]) => { if (languageAccess('translations.view', lang)) { cy.gcy('translations-table-cell-translation') @@ -46,8 +78,10 @@ export function testTranslations({ project, languages }: ProjectInfo) { if (scopes.includes('translations.edit')) { getRowTexts(1).forEach(([lang, text]) => { - if (languageAccess('translations.edit', lang)) { + // english translation is in task, so it's editable + if (languageAccess('translations.edit', lang) || lang === 'en') { cy.gcy('translations-table-cell-translation').contains(text).click(); + cy.waitForDom(); cy.gcy('global-editor').should('be.visible'); if (project.baseLanguage.tag !== lang) { diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index f4f419d720..d84ceb91cc 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -511,6 +511,7 @@ declare namespace DataCy { "task-select-item" | "task-select-search" | "task-state" | + "task-tooltip-content" | "tasks-filter-menu" | "tasks-header-add-task" | "tasks-header-filter-select" | @@ -593,6 +594,7 @@ declare namespace DataCy { "user-delete-organization-message-item" | "user-menu-language-switch" | "user-menu-logout" | + "user-menu-my-tasks" | "user-menu-organization-settings" | "user-menu-organization-switch" | "user-menu-server-administration" | diff --git a/webapp/src/component/security/UserMenu/UserPresentMenu.tsx b/webapp/src/component/security/UserMenu/UserPresentMenu.tsx index a632a4e09d..fa103aa12d 100644 --- a/webapp/src/component/security/UserMenu/UserPresentMenu.tsx +++ b/webapp/src/component/security/UserMenu/UserPresentMenu.tsx @@ -147,6 +147,7 @@ export const UserPresentMenu: React.FC = () => { component={Link} to={LINKS.MY_TASKS.build()} selected={location.pathname === LINKS.MY_TASKS.build()} + data-cy="user-menu-my-tasks" sx={{ display: 'flex', justifyContent: 'space-between', diff --git a/webapp/src/component/task/TaskTooltipContent.tsx b/webapp/src/component/task/TaskTooltipContent.tsx index 27deab7a1b..3d0d3c1697 100644 --- a/webapp/src/component/task/TaskTooltipContent.tsx +++ b/webapp/src/component/task/TaskTooltipContent.tsx @@ -56,6 +56,7 @@ export const TaskTooltipContent = ({ return ( diff --git a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx index 713d05b4dd..7573dde197 100644 --- a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx +++ b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx @@ -17,6 +17,8 @@ import { stopAndPrevent } from 'tg.fixtures/eventHandler'; import { TaskTooltip } from 'tg.component/task/TaskTooltip'; import { Link } from 'react-router-dom'; import { getTaskRedirect } from 'tg.component/task/utils'; +import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; +import clsx from 'clsx'; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; @@ -48,6 +50,10 @@ const StyledContainer = styled(Box)` margin-left: -4px; border-radius: 10px; + &.clickDisabled { + cursor: default; + } + &:hover .clearButton { display: block; } @@ -76,6 +82,8 @@ export const TranslationFlags: React.FC = ({ const { updateTranslation } = useTranslationsActions(); const [taskDetailData, setTaskDetailData] = useState(); + const { satisfiesPermission } = useProjectPermissions(); + const canViewTasks = satisfiesPermission('tasks.view'); const clearAutoTranslatedState = useApiMutation({ url: '/v2/projects/{projectId}/translations/{translationId}/dismiss-auto-translated-state', @@ -129,18 +137,21 @@ export const TranslationFlags: React.FC = ({ return ( {task && ( - - + + - - + + )} {translation.auto && ( From e2f64eccd155c5b3211a32bf72572d02be530956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Fri, 20 Sep 2024 16:09:27 +0200 Subject: [PATCH 21/26] chore: fix e2e tests --- e2e/cypress/common/permissions/keys.ts | 2 +- .../permissions/permissionsTask.cy.ts | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts diff --git a/e2e/cypress/common/permissions/keys.ts b/e2e/cypress/common/permissions/keys.ts index 1c98bffed7..6ec98a087d 100644 --- a/e2e/cypress/common/permissions/keys.ts +++ b/e2e/cypress/common/permissions/keys.ts @@ -57,7 +57,7 @@ export function testKeys(info: ProjectInfo) { !scopes.includes('translations.edit') && scopes.includes('translations.view') ) { - getCell('Czech text 1').click(); + getCell('German text 1').click(); cy.gcy('global-editor').should('not.exist'); } diff --git a/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts new file mode 100644 index 0000000000..55cf26cdb9 --- /dev/null +++ b/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts @@ -0,0 +1,34 @@ +import { + checkPermissions, + RUN, + visitProjectWithPermissions, +} from '../../../common/permissions/main'; + +describe('Permissions task', () => { + it('tasks.view', () => { + visitProjectWithPermissions({ scopes: ['tasks.view'] }).then( + (projectInfo) => { + checkPermissions(projectInfo, { + 'project-menu-item-dashboard': RUN, + 'project-menu-item-translations': RUN, + 'project-menu-item-export': RUN, + 'project-menu-item-integrate': RUN, + }); + } + ); + }); + + it('tasks.edit', () => { + visitProjectWithPermissions({ scopes: ['tasks.edit'] }).then( + (projectInfo) => { + checkPermissions(projectInfo, { + 'project-menu-item-dashboard': RUN, + 'project-menu-item-translations': RUN, + 'project-menu-item-import': RUN, + 'project-menu-item-export': RUN, + 'project-menu-item-integrate': RUN, + }); + } + ); + }); +}); From 8fd6a1cb821f62abdc5609b82e62625f8910ad54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Fri, 20 Sep 2024 16:56:56 +0200 Subject: [PATCH 22/26] chore: fix e2e tests --- .../testDataBuilder/data/PermissionsTestData.kt | 8 ++++++++ .../development/testDataBuilder/data/TaskTestData.kt | 1 + .../data/src/main/kotlin/io/tolgee/model/enums/Scope.kt | 2 +- .../internal/e2eData/PermissionsE2eDataController.kt | 4 +++- .../projects/permissions/permissionsServerAdmin.1.cy.ts | 4 ++-- .../projects/permissions/permissionsServerAdmin.2.cy.ts | 4 ++-- .../projects/permissions/permissionsServerAdmin.3.cy.ts | 4 ++-- .../e2e/projects/permissions/permissionsTask.cy.ts | 3 ++- 8 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt index 8b0ed53ab1..35656b72c5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt @@ -17,6 +17,7 @@ class PermissionsTestData { var projectBuilder: ProjectBuilder var organizationBuilder: OrganizationBuilder var admin: UserAccountBuilder + var serverAdmin: UserAccountBuilder lateinit var addedProject: Project lateinit var englishLanguage: Language lateinit var keys: List @@ -24,6 +25,13 @@ class PermissionsTestData { val root: TestDataBuilder = TestDataBuilder().apply { admin = addUserAccount { username = "admin@admin.com" } + + serverAdmin = addUserAccount { + username = "Server admin" + name = "Server admin" + role = UserAccount.Role.ADMIN + } + organizationBuilder = admin.defaultOrganizationBuilder val member = addUserAccount { username = "member@member.com" } 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 a41d8ea8cc..b3c0cbcd78 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,6 +2,7 @@ package io.tolgee.development.testDataBuilder.data import io.tolgee.development.testDataBuilder.builders.* import io.tolgee.model.Language +import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt index cb37938dcc..3e31992d44 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt @@ -46,7 +46,7 @@ enum class Scope( private val translationsView = HierarchyItem(TRANSLATIONS_VIEW, listOf(keysView)) private val screenshotsView = HierarchyItem(SCREENSHOTS_VIEW, listOf(keysView)) private val translationsEdit = HierarchyItem(TRANSLATIONS_EDIT, listOf(translationsView)) - private val tasksView = HierarchyItem(TASKS_VIEW) + private val tasksView = HierarchyItem(TASKS_VIEW, listOf(translationsView)) val hierarchy = HierarchyItem( diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt index 3a8469842f..2049a8fce9 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt @@ -37,7 +37,9 @@ class PermissionsE2eDataController() : AbstractE2eDataController() { translateLanguageTags = translateLanguageTags, stateChangeLanguageTags = stateChangeLanguageTags, ) - this.permissionsTestData.addTasks(mutableSetOf(user)) + this.permissionsTestData.addTasks( + mutableSetOf(user, permissionsTestData.serverAdmin.self) + ) return generate() } diff --git a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts index 3bc0e0ef96..ff72f9628f 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.1.cy.ts @@ -13,10 +13,10 @@ import { } from '../../../common/shared'; describe('Server admin 1', { retries: { runMode: 5 } }, () => { - it('admin', () => { + it('Server admin', () => { visitProjectWithPermissions({ scopes: ['admin'] }).then((projectInfo) => { // login as admin - login('admin', 'admin'); + login('Server admin', 'admin'); // check that admin has no warning banner on his home page switchToOrganization('admin'); diff --git a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts index 79f19ca774..18ae5a2a6e 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts @@ -7,10 +7,10 @@ import { } from '../../../common/permissions/main'; describe('Server admin 2', () => { - it('admin', () => { + it('Server admin', () => { visitProjectWithPermissions({ scopes: ['admin'] }).then((projectInfo) => { // login as admin - login('admin', 'admin'); + login('Server admin', 'admin'); checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, 'project-menu-item-translations': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts index 854bd177f6..39b813da13 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.3.cy.ts @@ -7,10 +7,10 @@ import { } from '../../../common/permissions/main'; describe('Server admin 3', () => { - it('admin', () => { + it('Server admin', () => { visitProjectWithPermissions({ scopes: ['admin'] }).then((projectInfo) => { // login as admin - login('admin', 'admin'); + login('Server admin', 'admin'); checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, 'project-menu-item-translations': SKIP, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts index 55cf26cdb9..9c1174a4c8 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts @@ -11,6 +11,7 @@ describe('Permissions task', () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, + 'project-menu-item-tasks': RUN, 'project-menu-item-export': RUN, 'project-menu-item-integrate': RUN, }); @@ -24,7 +25,7 @@ describe('Permissions task', () => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, - 'project-menu-item-import': RUN, + 'project-menu-item-tasks': RUN, 'project-menu-item-export': RUN, 'project-menu-item-integrate': RUN, }); From c470489a17ed68bad96eccdc460c796f040e8634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Fri, 20 Sep 2024 17:02:18 +0200 Subject: [PATCH 23/26] chore: fix e2e tests --- .../testDataBuilder/data/PermissionsTestData.kt | 11 ++++++----- .../development/testDataBuilder/data/TaskTestData.kt | 1 - .../internal/e2eData/PermissionsE2eDataController.kt | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt index 35656b72c5..cb3d63ddf8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt @@ -26,11 +26,12 @@ class PermissionsTestData { TestDataBuilder().apply { admin = addUserAccount { username = "admin@admin.com" } - serverAdmin = addUserAccount { - username = "Server admin" - name = "Server admin" - role = UserAccount.Role.ADMIN - } + serverAdmin = + addUserAccount { + username = "Server admin" + name = "Server admin" + role = UserAccount.Role.ADMIN + } organizationBuilder = admin.defaultOrganizationBuilder 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 b3c0cbcd78..a41d8ea8cc 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,7 +2,6 @@ package io.tolgee.development.testDataBuilder.data import io.tolgee.development.testDataBuilder.builders.* import io.tolgee.model.Language -import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt index 2049a8fce9..c381a37e3f 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt @@ -38,7 +38,7 @@ class PermissionsE2eDataController() : AbstractE2eDataController() { stateChangeLanguageTags = stateChangeLanguageTags, ) this.permissionsTestData.addTasks( - mutableSetOf(user, permissionsTestData.serverAdmin.self) + mutableSetOf(user, permissionsTestData.serverAdmin.self), ) return generate() } From 78959e55bcf93e9a722d672554917274aced3b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Fri, 20 Sep 2024 17:22:13 +0200 Subject: [PATCH 24/26] chore: fix e2e tests --- e2e/cypress/common/permissions/keys.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/cypress/common/permissions/keys.ts b/e2e/cypress/common/permissions/keys.ts index 6ec98a087d..cfdc5635b2 100644 --- a/e2e/cypress/common/permissions/keys.ts +++ b/e2e/cypress/common/permissions/keys.ts @@ -36,7 +36,10 @@ export function testKeys(info: ProjectInfo) { if (scopes.includes('screenshots.view')) { cy.gcy('translations-table-cell').first().focus(); - cy.gcy('translations-cell-screenshots-button').should('exist').click(); + cy.gcy('translations-cell-screenshots-button') + .first() + .should('exist') + .click(); cy.gcy('screenshot-thumbnail').should('be.visible'); if (scopes.includes('screenshots.delete')) { cy.gcy('screenshot-thumbnail').trigger('mouseover'); From 8ed95d39723d937c9d1d7cdf001afdc9b03524b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Fri, 20 Sep 2024 20:29:10 +0200 Subject: [PATCH 25/26] fix: translation possibly undefined --- .../views/projects/translations/cell/TranslationFlags.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx index 7573dde197..5635a34694 100644 --- a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx +++ b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx @@ -23,6 +23,7 @@ import clsx from 'clsx'; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; type TaskModel = components['schemas']['TaskModel']; +type TranslationModel = components['schemas']['TranslationModel']; const StyledWrapper = styled('div')` display: flex; @@ -153,7 +154,7 @@ export const TranslationFlags: React.FC = ({ )} - {translation.auto && ( + {translation?.auto && ( = ({ /> )} - {translation.outdated && ( + {translation?.outdated && ( Date: Fri, 20 Sep 2024 20:38:42 +0200 Subject: [PATCH 26/26] fix: translation possibly undefined --- webapp/src/views/projects/translations/cell/TranslationFlags.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx index 5635a34694..2abf6f5432 100644 --- a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx +++ b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx @@ -23,7 +23,6 @@ import clsx from 'clsx'; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; type TaskModel = components['schemas']['TaskModel']; -type TranslationModel = components['schemas']['TranslationModel']; const StyledWrapper = styled('div')` display: flex;