From f945840f72902029d647918a96e6fb338081283e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Tue, 11 Jul 2023 09:10:53 +0200 Subject: [PATCH 01/17] feat: batch operations FE --- .../batch/BatchJobChunkExecutionQueue.kt | 4 +- .../batch/BatchJobConcurrentLauncher.kt | 2 +- .../io/tolgee/batch/ChunkProcessingUtil.kt | 32 +- e2e/cypress/common/groupActions.ts | 10 + e2e/cypress/common/permissions/keys.ts | 7 +- ...rmissionsKeys.ts => permissionsKeys.cy.ts} | 0 e2e/cypress/e2e/translations/comments.cy.ts | 1 + .../e2e/translations/groupActions.cy.ts | 9 +- e2e/cypress/e2e/translations/namespaces.cy.ts | 13 +- e2e/cypress/support/dataCyType.d.ts | 7 +- .../NamespaceSelector/NamespaceSelector.tsx | 2 +- webapp/src/component/billing/Usage.tsx | 38 +- .../form/LanguagesSelect/LanguagesSelect.tsx | 10 +- .../LanguagesSelect/getLanguagesContent.tsx | 6 +- .../component/searchSelect/SearchSelect.tsx | 15 +- .../PermissionsAdvanced.tsx | 8 + .../useScopeTranslations.tsx | 3 + ...eateProviderNew.tsx => createProvider.tsx} | 24 +- webapp/src/globalContext/GlobalContext.tsx | 4 +- webapp/src/hooks/ProjectContext.tsx | 174 +++++ webapp/src/hooks/ProjectProvider.tsx | 64 -- webapp/src/hooks/useProject.ts | 22 +- webapp/src/service/apiSchema.generated.ts | 537 ++++++++++++-- .../src/service/billingApiSchema.generated.ts | 654 ++---------------- .../useBatchOperationStatusTranslate.ts | 23 + .../useBatchOperationTypeTranslation.ts | 25 + .../translationTools/useErrorTranslation.ts | 3 + webapp/src/views/projects/BaseProjectView.tsx | 9 +- webapp/src/views/projects/ProjectRouter.tsx | 6 +- .../src/views/projects/WebsocketPreview.tsx | 2 +- .../BatchOperations/BatchOperations.tsx | 193 ++++++ .../BatchOperationsChangeIndicator.tsx | 67 ++ .../BatchOperations/BatchSelect.tsx | 59 ++ .../BatchOperations/OperationAddTags.tsx | 101 +++ .../OperationAutoTranslate.tsx | 78 +++ .../OperationChangeNamespace.tsx | 68 ++ .../OperationClearTranslations.tsx | 73 ++ .../OperationCopyTranslations.tsx | 110 +++ .../BatchOperations/OperationDelete.tsx | 61 ++ .../OperationMarkAsReviewed.tsx | 75 ++ .../OperationMarkAsTranslated.tsx | 74 ++ .../BatchOperations/OperationRemoveTags.tsx | 102 +++ .../OperationsSummary/BatchIndicator.tsx | 34 + .../BatchOperationDialog.tsx | 113 +++ .../OperationsSummary/BatchProgress.tsx | 32 + .../OperationsSummary/OperationsList.tsx | 83 +++ .../OperationsSummary/OperationsSummary.tsx | 46 ++ .../OperationsSummary/utils.ts | 31 + .../translations/BatchOperations/types.ts | 21 + .../views/projects/translations/Tags/Tag.tsx | 25 +- .../projects/translations/Tags/TagInput.tsx | 20 +- .../TranslationTools/useTranslationTools.ts | 6 +- .../projects/translations/Translations.tsx | 6 + .../TranslationsList/TranslationsList.tsx | 3 - .../translations/TranslationsSelection.tsx | 107 --- .../TranslationsTable/TranslationsTable.tsx | 3 - .../translations/TranslationsToolbar.tsx | 10 +- .../translations/context/ColumnsContext.ts | 4 +- .../translations/context/HeaderNsContext.ts | 4 +- .../context/TranslationsContext.ts | 4 +- .../context/services/useWebsocketListener.ts | 2 +- .../src/websocket-client/WebsocketClient.ts | 13 +- webapp/tsconfig.extend.json | 3 +- 63 files changed, 2384 insertions(+), 961 deletions(-) create mode 100644 e2e/cypress/common/groupActions.ts rename e2e/cypress/e2e/projects/permissions/{permissionsKeys.ts => permissionsKeys.cy.ts} (100%) rename webapp/src/fixtures/{createProviderNew.tsx => createProvider.tsx} (67%) create mode 100644 webapp/src/hooks/ProjectContext.tsx delete mode 100644 webapp/src/hooks/ProjectProvider.tsx create mode 100644 webapp/src/translationTools/useBatchOperationStatusTranslate.ts create mode 100644 webapp/src/translationTools/useBatchOperationTypeTranslation.ts create mode 100644 webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationAddTags.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationAutoTranslate.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationChangeNamespace.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationClearTranslations.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationCopyTranslations.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationDelete.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationMarkAsReviewed.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationMarkAsTranslated.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationRemoveTags.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchIndicator.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsSummary.tsx create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts create mode 100644 webapp/src/views/projects/translations/BatchOperations/types.ts delete mode 100644 webapp/src/views/projects/translations/TranslationsSelection.tsx diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobChunkExecutionQueue.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobChunkExecutionQueue.kt index 4365b6b2e7..3776d42520 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobChunkExecutionQueue.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobChunkExecutionQueue.kt @@ -6,6 +6,7 @@ import io.tolgee.model.batch.BatchJobChunkExecution import io.tolgee.model.batch.BatchJobChunkExecutionStatus import io.tolgee.pubSub.RedisPubSubReceiverConfiguration import io.tolgee.util.Logging +import io.tolgee.util.logger import org.hibernate.LockOptions import org.springframework.context.annotation.Lazy import org.springframework.context.event.EventListener @@ -39,7 +40,7 @@ class BatchJobChunkExecutionQueue( } } - @Scheduled(fixedDelay = 60000) + @Scheduled(fixedRate = 60000) fun populateQueue() { val data = entityManager.createQuery( """ @@ -54,6 +55,7 @@ class BatchJobChunkExecutionQueue( "javax.persistence.lock.timeout", LockOptions.SKIP_LOCKED ).resultList + logger.debug("Adding ${data.size} items to queue ${System.identityHashCode(this)}") addExecutionsToLocalQueue(data) } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobConcurrentLauncher.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobConcurrentLauncher.kt index 43ba65c6c2..3d213c768f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobConcurrentLauncher.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobConcurrentLauncher.kt @@ -21,7 +21,7 @@ import kotlin.coroutines.CoroutineContext class BatchJobConcurrentLauncher( private val batchProperties: BatchProperties, private val batchJobChunkExecutionQueue: BatchJobChunkExecutionQueue, - private val currentDateProvider: CurrentDateProvider + private val currentDateProvider: CurrentDateProvider, ) : Logging { companion object { val runningInstances: ConcurrentHashMap.KeySetView = diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt index fb1d4e36fe..01bf5f9b9b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessingUtil.kt @@ -14,6 +14,7 @@ import java.util.* import javax.persistence.EntityManager import kotlin.coroutines.CoroutineContext import kotlin.math.pow +import kotlin.system.measureTimeMillis open class ChunkProcessingUtil( val execution: BatchJobChunkExecution, @@ -21,23 +22,26 @@ open class ChunkProcessingUtil( private val coroutineContext: CoroutineContext, ) : Logging { open fun processChunk() { - try { - val processor = batchJobService.getProcessor(job.type) - processor.process(job, toProcess, coroutineContext) { - if (it != toProcess.size) { - progressManager.publishSingleChunkProgress(job.id, it) + val time = measureTimeMillis { + try { + val processor = batchJobService.getProcessor(job.type) + processor.process(job, toProcess, coroutineContext) { + if (it != toProcess.size) { + progressManager.publishSingleChunkProgress(job.id, it) + } + } + successfulTargets = toProcess + execution.status = BatchJobChunkExecutionStatus.SUCCESS + handleActivity() + } catch (e: Throwable) { + handleException(e) + } finally { + successfulTargets?.let { + execution.successTargets = it } - } - successfulTargets = toProcess - execution.status = BatchJobChunkExecutionStatus.SUCCESS - handleActivity() - } catch (e: Throwable) { - handleException(e) - } finally { - successfulTargets?.let { - execution.successTargets = it } } + logger.debug("Chunk ${execution.id} executed in ${time}ms") } private fun handleActivity() { diff --git a/e2e/cypress/common/groupActions.ts b/e2e/cypress/common/groupActions.ts new file mode 100644 index 0000000000..9348af202e --- /dev/null +++ b/e2e/cypress/common/groupActions.ts @@ -0,0 +1,10 @@ +import { confirmStandard } from './shared'; + +export function deleteSelected() { + cy.gcy('batch-operations-select').click(); + cy.gcy('batch-select-item').contains('Delete').click(); + cy.gcy('batch-operations-submit-button').click(); + confirmStandard(); + cy.wait(2000); + cy.gcy('batch-operation-dialog-ok', { timeout: 10_000 }).click(); +} diff --git a/e2e/cypress/common/permissions/keys.ts b/e2e/cypress/common/permissions/keys.ts index b85c11b4fa..d5d2784f39 100644 --- a/e2e/cypress/common/permissions/keys.ts +++ b/e2e/cypress/common/permissions/keys.ts @@ -1,7 +1,8 @@ import { satisfiesLanguageAccess } from '../../../../webapp/src/fixtures/permissions'; import { createKey } from '../apiCalls/common'; +import { deleteSelected } from '../groupActions'; import { waitForGlobalLoading } from '../loading'; -import { assertMessage, confirmStandard } from '../shared'; +import { confirmStandard } from '../shared'; import { createTag } from '../tags'; import { editCell } from '../translations'; import { getLanguageId, getLanguages, ProjectInfo } from './shared'; @@ -65,8 +66,6 @@ export function testKeys(info: ProjectInfo) { if (scopes.includes('keys.delete')) { cy.gcy('translations-row-checkbox').first().click(); - cy.gcy('translations-delete-button').click(); - confirmStandard(); - assertMessage('Translations deleted!'); + deleteSelected(); } } diff --git a/e2e/cypress/e2e/projects/permissions/permissionsKeys.ts b/e2e/cypress/e2e/projects/permissions/permissionsKeys.cy.ts similarity index 100% rename from e2e/cypress/e2e/projects/permissions/permissionsKeys.ts rename to e2e/cypress/e2e/projects/permissions/permissionsKeys.cy.ts diff --git a/e2e/cypress/e2e/translations/comments.cy.ts b/e2e/cypress/e2e/translations/comments.cy.ts index b7113a6fe1..c09e5e91bd 100644 --- a/e2e/cypress/e2e/translations/comments.cy.ts +++ b/e2e/cypress/e2e/translations/comments.cy.ts @@ -114,6 +114,7 @@ function logInAs(user: string) { login(user, 'admin'); visitList(); enterProject("Franta's project", 'franta'); + cy.waitForDom(); } function userCanResolveComment(index: number, lang: string, comment: string) { diff --git a/e2e/cypress/e2e/translations/groupActions.cy.ts b/e2e/cypress/e2e/translations/groupActions.cy.ts index 70ebf6c3fd..443aec6d57 100644 --- a/e2e/cypress/e2e/translations/groupActions.cy.ts +++ b/e2e/cypress/e2e/translations/groupActions.cy.ts @@ -6,7 +6,8 @@ import { import { waitForGlobalLoading } from '../../common/loading'; import { generateExampleKeys } from '../../common/apiCalls/testData/testData'; import { deleteProject } from '../../common/apiCalls/common'; -import { confirmStandard, gcy } from '../../common/shared'; +import { gcy } from '../../common/shared'; +import { deleteSelected } from '../../common/groupActions'; describe('Group actions', () => { let project: ProjectDTO = null; @@ -32,8 +33,7 @@ describe('Group actions', () => { gcy('translations-row-checkbox').first().click(); gcy('translations-select-all-button').click(); waitForGlobalLoading(500); - gcy('translations-delete-button').click(); - confirmStandard(); + deleteSelected(); waitForGlobalLoading(500); gcy('global-empty-list').should('be.visible'); }); @@ -43,8 +43,7 @@ describe('Group actions', () => { gcy('translations-select-all-button').click(); waitForGlobalLoading(); gcy('translations-row-checkbox').first().click(); - gcy('translations-delete-button').click(); - confirmStandard(); + deleteSelected(); waitForGlobalLoading(500); gcy('translations-key-count').contains('1').should('be.visible'); }); diff --git a/e2e/cypress/e2e/translations/namespaces.cy.ts b/e2e/cypress/e2e/translations/namespaces.cy.ts index c6c0a11db8..d2afa65a90 100644 --- a/e2e/cypress/e2e/translations/namespaces.cy.ts +++ b/e2e/cypress/e2e/translations/namespaces.cy.ts @@ -12,10 +12,7 @@ import { selectInSelect, } from '../../common/shared'; import { selectNamespace } from '../../common/namespace'; -import { - awaitPendingRequests, - setupRequestAwaiter, -} from '../../common/requestsAwaiter'; +import { setupRequestAwaiter } from '../../common/requestsAwaiter'; describe('namespaces in translations', () => { beforeEach(() => { @@ -34,10 +31,10 @@ describe('namespaces in translations', () => { waitForGlobalLoading(); }); - afterEach(() => { - namespaces.clean({ failOnStatusCode: false, timeout: 60000 }); - awaitPendingRequests(); - }); + // afterEach(() => { + // namespaces.clean({ failOnStatusCode: false, timeout: 60000 }); + // awaitPendingRequests(); + // }); it('displays keys with namespaces correctly', () => { gcy('translations-namespace-banner').contains('ns-1').should('be.visible'); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 81a7263ee1..08828db14f 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -68,6 +68,12 @@ declare namespace DataCy { "avatar-upload-button" | "avatar-upload-file-input" | "base-language-select" | + "batch-operation-dialog-end-status" | + "batch-operation-dialog-minimize" | + "batch-operation-dialog-ok" | + "batch-operations-select" | + "batch-operations-submit-button" | + "batch-select-item" | "billing-actual-extra-credits" | "billing-actual-period" | "billing-actual-period-end" | @@ -394,7 +400,6 @@ declare namespace DataCy { "translations-cell-tab-history" | "translations-comments-input" | "translations-comments-load-more-button" | - "translations-delete-button" | "translations-filter-clear-all" | "translations-filter-option" | "translations-filter-select" | diff --git a/webapp/src/component/NamespaceSelector/NamespaceSelector.tsx b/webapp/src/component/NamespaceSelector/NamespaceSelector.tsx index 62ccfc14ad..ed728ba977 100644 --- a/webapp/src/component/NamespaceSelector/NamespaceSelector.tsx +++ b/webapp/src/component/NamespaceSelector/NamespaceSelector.tsx @@ -85,7 +85,7 @@ export const NamespaceSelector: React.FC = ({ onSelect={onChange} items={existingOptions} value={value || ''} - SelectProps={{ size: 'small' }} + SelectProps={{ size: 'small', ...SearchSelectProps?.SelectProps }} /> {Boolean(dialogOpen) && ( { <>{children} ); + if (!progressData || !showStats) { + return null; + } + return ( - {progressData && showStats && ( - - - - - } - > - - - - - - )} + + + + + } + > + + + + + ); }; diff --git a/webapp/src/component/common/form/LanguagesSelect/LanguagesSelect.tsx b/webapp/src/component/common/form/LanguagesSelect/LanguagesSelect.tsx index aa93aa7f96..74e0e72f46 100644 --- a/webapp/src/component/common/form/LanguagesSelect/LanguagesSelect.tsx +++ b/webapp/src/component/common/form/LanguagesSelect/LanguagesSelect.tsx @@ -1,5 +1,5 @@ import { FunctionComponent } from 'react'; -import { Select, styled, Typography } from '@mui/material'; +import { InputLabel, Select, styled, Typography } from '@mui/material'; import FormControl from '@mui/material/FormControl'; import { components } from 'tg.service/apiSchema.generated'; @@ -35,6 +35,8 @@ export type Props = { disabledLanguages?: number[] | undefined; value: string[]; context: string; + enableEmpty?: boolean; + placeholder?: string; }; export const LanguagesSelect: FunctionComponent = (props) => { @@ -54,6 +56,11 @@ export const LanguagesSelect: FunctionComponent = (props) => { variant="outlined" size="small" > + {props.placeholder && props.value.length === 0 && ( + + {props.placeholder} + + )} = (props) => { languages: props.languages, value: props.value, disabledLanguages: props.disabledLanguages, + enableEmpty: props.enableEmpty, })} diff --git a/webapp/src/component/common/form/LanguagesSelect/getLanguagesContent.tsx b/webapp/src/component/common/form/LanguagesSelect/getLanguagesContent.tsx index 518a19a0d0..6d7b1ad362 100644 --- a/webapp/src/component/common/form/LanguagesSelect/getLanguagesContent.tsx +++ b/webapp/src/component/common/form/LanguagesSelect/getLanguagesContent.tsx @@ -13,6 +13,7 @@ type Props = { languages: LanguageModel[]; value: string[]; disabledLanguages: number[] | undefined; + enableEmpty?: boolean; }; const messaging = container.resolve(MessageService); @@ -22,6 +23,7 @@ export const getLanguagesContent = ({ value, onChange, disabledLanguages, + enableEmpty, }: Props) => { const handleLanguageChange = (lang: string) => () => { const baseLang = languages.find((l) => l.base)?.tag; @@ -29,11 +31,11 @@ export const getLanguagesContent = ({ ? value.filter((l) => l !== lang) : putBaseLangFirst([...value, lang], baseLang); - if (!result?.length) { + if (!result?.length && !enableEmpty) { messaging.error(); return; } - onChange(result); + onChange(result || []); }; return languages.map((lang) => ( diff --git a/webapp/src/component/searchSelect/SearchSelect.tsx b/webapp/src/component/searchSelect/SearchSelect.tsx index 14bf007bb1..5073b5f036 100644 --- a/webapp/src/component/searchSelect/SearchSelect.tsx +++ b/webapp/src/component/searchSelect/SearchSelect.tsx @@ -14,7 +14,6 @@ const StyledInputContent = styled('div')` overflow: hidden; text-overflow: ellipsis; margin-right: -5px; - contain: size; height: 23px; `; @@ -33,6 +32,7 @@ type Props = { renderValue?: (value: T | undefined) => React.ReactNode; SelectProps?: React.ComponentProps; compareFunction?: (prompt: string, label: string) => boolean; + noContain?: boolean; }; export function SearchSelect({ @@ -49,6 +49,7 @@ export function SearchSelect({ renderValue, SelectProps, compareFunction, + noContain, }: Props) { const anchorEl = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -67,17 +68,19 @@ export function SearchSelect({ }; const myRenderValue = () => ( - + {renderValue ? renderValue(value) : (valueItem ? valueItem.name : value) || ''} ); - const handleOnAddNew = (searchValue: string) => { - setIsOpen(false); - onAddNew?.(searchValue); - }; + const handleOnAddNew = onAddNew + ? (searchValue: string) => { + setIsOpen(false); + onAddNew?.(searchValue); + } + : undefined; const valueItem = items.find((i) => i.value === value); diff --git a/webapp/src/ee/PermissionsAdvanced/PermissionsAdvanced.tsx b/webapp/src/ee/PermissionsAdvanced/PermissionsAdvanced.tsx index 510bf2050e..69c8566859 100644 --- a/webapp/src/ee/PermissionsAdvanced/PermissionsAdvanced.tsx +++ b/webapp/src/ee/PermissionsAdvanced/PermissionsAdvanced.tsx @@ -96,6 +96,14 @@ export const PermissionsAdvanced: React.FC = ({ }, ], }, + { + label: t('permissions_item_batch_operations'), + children: [ + { value: 'batch-jobs.view' }, + { value: 'batch-jobs.cancel' }, + { value: 'batch-auto-translate' }, + ], + }, { label: t('permissions_item_members'), children: [ diff --git a/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx b/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx index 60ac72d615..48afdb0952 100644 --- a/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx +++ b/webapp/src/ee/PermissionsAdvanced/useScopeTranslations.tsx @@ -28,6 +28,9 @@ export const useScopeTranslations = () => { 'members.edit': t('permissions_item_members_edit'), 'languages.edit': t('permissions_item_languages_edit'), 'activity.view': t('permissions_item_activity_view'), + 'batch-jobs.view': t('permissions_item_batch_jobs_view'), + 'batch-jobs.cancel': t('permissions_item_batch_jobs_cancel'), + 'batch-auto-translate': t('permissions_item_batch_jobs_auto_translate'), }; return { diff --git a/webapp/src/fixtures/createProviderNew.tsx b/webapp/src/fixtures/createProvider.tsx similarity index 67% rename from webapp/src/fixtures/createProviderNew.tsx rename to webapp/src/fixtures/createProvider.tsx index b50e09394a..4f6822e735 100644 --- a/webapp/src/fixtures/createProviderNew.tsx +++ b/webapp/src/fixtures/createProvider.tsx @@ -3,14 +3,16 @@ import { createContext, useContextSelector } from 'use-context-selector'; type SelectorType = (state: StateType) => ReturnType; -export const createProviderNew = ( - controller: (props: ProviderProps) => [state: StateType, actions: Actions] +export const createProvider = ( + controller: ( + props: ProviderProps + ) => [state: StateType, actions: Actions] | undefined | null ) => { const StateContext = createContext(null as any); const DispatchContext = React.createContext(null as any); const Provider: React.FC = ({ children, ...props }) => { - const [state, _actions] = controller(props as any); + const [state, _actions] = controller(props as any) || []; const actionsRef = useRef(_actions); actionsRef.current = _actions; @@ -18,12 +20,18 @@ export const createProviderNew = ( // stable actions const actions = useMemo(() => { const result = {}; - Object.keys(actionsRef.current as any).map((key) => { - result[key] = (...args) => - (actionsRef.current[key] as CallableFunction)?.(...args); - }); + if (actionsRef.current) { + Object.keys(actionsRef.current as any).map((key) => { + result[key] = (...args) => + (actionsRef.current?.[key] as CallableFunction)?.(...args); + }); + } return result as Actions; - }, [actionsRef]); + }, [actionsRef, Boolean(state)]); + + if (!state) { + return null; + } return ( diff --git a/webapp/src/globalContext/GlobalContext.tsx b/webapp/src/globalContext/GlobalContext.tsx index bd8cda2aba..4707ea8280 100644 --- a/webapp/src/globalContext/GlobalContext.tsx +++ b/webapp/src/globalContext/GlobalContext.tsx @@ -1,13 +1,13 @@ import { useOrganizationUsageService } from './useOrganizationUsageService'; import { useInitialDataService } from './useInitialDataService'; import { globalContext } from './globalActions'; -import { createProviderNew } from 'tg.fixtures/createProviderNew'; +import { createProvider } from 'tg.fixtures/createProvider'; import { components } from 'tg.service/apiSchema.generated'; type UsageModel = components['schemas']['PublicUsageModel']; export const [GlobalProvider, useGlobalActions, useGlobalContext] = - createProviderNew(() => { + createProvider(() => { const initialData = useInitialDataService(); const organizationUsage = useOrganizationUsageService({ diff --git a/webapp/src/hooks/ProjectContext.tsx b/webapp/src/hooks/ProjectContext.tsx new file mode 100644 index 0000000000..04df215458 --- /dev/null +++ b/webapp/src/hooks/ProjectContext.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { createProvider } from 'tg.fixtures/createProvider'; +import { useGlobalLoading } from 'tg.component/GlobalLoading'; +import { + BatchJobProgress, + WebsocketClient, +} from 'tg.websocket-client/WebsocketClient'; +import { AppState } from 'tg.store/index'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; +import { GlobalError } from '../error/GlobalError'; +import { useApiQuery } from '../service/http/useQueryApi'; +import { + BatchJobModel, + BatchJobStatus, +} from 'tg.views/projects/translations/BatchOperations/types'; + +type BatchJobUpdateModel = { + totalItems: number; + progress: number; + status: BatchJobStatus; + id: number; +}; + +type Props = { + id: number; +}; + +export const [ProjectContext, useProjectActions, useProjectContext] = + createProvider(({ id }: Props) => { + const [connected, setConnected] = useState(); + + const [knownJobs, setKnownJobs] = useState([]); + + const project = useApiQuery({ + url: '/v2/projects/{projectId}', + method: 'get', + path: { projectId: id }, + }); + + const settings = useApiQuery({ + url: '/v2/projects/{projectId}/machine-translation-service-settings', + method: 'get', + path: { projectId: id }, + }); + + const batchJobsLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/current-batch-jobs', + method: 'get', + path: { projectId: id }, + options: { + enabled: Boolean(connected), + staleTime: 0, + onSuccess(data) { + setBatchOperations( + ( + data._embedded?.batchJobs?.map((job) => { + // if data about the progress already exist, don't override them + // because that can cause out of order issues + const existingProgress = batchOperations?.find( + (o) => o.id === job.id + ); + return { + ...job, + status: existingProgress?.status ?? job.status, + totalItems: existingProgress?.totalItems ?? job.totalItems, + progress: existingProgress?.progress ?? job.progress, + }; + }) || [] + ).reverse() + ); + }, + }, + }); + + const [batchOperations, setBatchOperations] = + useState<(Partial & BatchJobUpdateModel)[]>(); + + const jwtToken = useSelector( + (state: AppState) => state.global.security.jwtToken + ); + + const changeHandler = ({ data }: BatchJobProgress) => { + const exists = batchOperations?.find((job) => job.id === data.jobId); + if (!exists) { + if (!knownJobs.includes(data.jobId)) { + // only refetch jobs first time we see unknown job + setKnownJobs((jobs) => [...jobs, data.jobId]); + setBatchOperations((jobs) => [ + ...(jobs || []), + { + id: data.jobId, + progress: data.processed, + totalItems: data.total, + status: data.status, + }, + ]); + batchJobsLoadable.refetch(); + } + } else { + setBatchOperations((jobs) => + jobs?.map((job) => { + if (job.id === data.jobId) { + return { + ...job, + totalItems: data.total ?? job.totalItems, + progress: data.processed ?? job.progress, + status: data.status ?? job.status, + }; + } + return job; + }) + ); + } + }; + + const changeHandlerRef = useRef(changeHandler); + changeHandlerRef.current = changeHandler; + + useEffect(() => { + if (jwtToken) { + const client = WebsocketClient({ + authentication: { jwtToken: jwtToken }, + serverUrl: process.env.REACT_APP_API_URL, + onConnected: () => setConnected(true), + onConnectionClose: () => setConnected(false), + }); + + client.subscribe(`/projects/${id}/batch-job-progress`, (e) => { + changeHandlerRef?.current(e); + }); + return () => client.disconnect(); + } + }, [id, jwtToken]); + + const { updatePreferredOrganization } = usePreferredOrganization(); + + useEffect(() => { + if (project.data?.organizationOwner) { + updatePreferredOrganization(project.data.organizationOwner.id); + } + }, [project.data]); + + const isLoading = project.isLoading || settings.isLoading; + + useGlobalLoading(isLoading); + + if (isLoading) { + return null; + } + + if (project.error || settings.error) { + throw new GlobalError( + 'Unexpected error occurred', + project.error?.code || settings.error?.code || 'Loadable error' + ); + } + + const contextData = { + project: project.data, + enabledMtServices: settings.data?._embedded?.languageConfigs, + batchOperations: batchOperations?.filter( + (o) => o.type + ) as BatchJobModel[], + }; + + const actions = { + refetchSettings: settings.refetch, + refetchBatchJobs: batchJobsLoadable.refetch, + }; + + return [contextData, actions]; + }); diff --git a/webapp/src/hooks/ProjectProvider.tsx b/webapp/src/hooks/ProjectProvider.tsx deleted file mode 100644 index 280770939f..0000000000 --- a/webapp/src/hooks/ProjectProvider.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { createContext } from 'react'; -import { useEffect } from 'react'; -import { FullPageLoading } from 'tg.component/common/FullPageLoading'; -import { GlobalError } from '../error/GlobalError'; -import { components } from '../service/apiSchema.generated'; -import { useApiQuery } from '../service/http/useQueryApi'; -import { usePreferredOrganization } from 'tg.globalContext/helpers'; - -type ProjectModel = components['schemas']['ProjectModel']; -type LanguageConfigItemModel = components['schemas']['LanguageConfigItemModel']; - -export const ProjectContext = createContext<{ - project: ProjectModel; - enabledMtServices?: LanguageConfigItemModel[]; - refetchSettings: () => void; -} | null>(null); - -export const ProjectProvider: React.FC<{ id: number }> = ({ id, children }) => { - const project = useApiQuery({ - url: '/v2/projects/{projectId}', - method: 'get', - path: { projectId: id }, - }); - - const settings = useApiQuery({ - url: '/v2/projects/{projectId}/machine-translation-service-settings', - method: 'get', - path: { projectId: id }, - }); - - const { updatePreferredOrganization } = usePreferredOrganization(); - - useEffect(() => { - if (project.data?.organizationOwner) { - updatePreferredOrganization(project.data.organizationOwner.id); - } - }, [project.data]); - - if (project.isLoading || settings.isLoading) { - return ; - } - - if (project.data) { - return ( - - {children} - - ); - } - - if (project.error || settings.error) { - throw new GlobalError( - 'Unexpected error occurred', - project.error?.code || settings.error?.code || 'Loadable error' - ); - } - return null; -}; diff --git a/webapp/src/hooks/useProject.ts b/webapp/src/hooks/useProject.ts index 4c25508982..968be1a4ca 100644 --- a/webapp/src/hooks/useProject.ts +++ b/webapp/src/hooks/useProject.ts @@ -1,25 +1,11 @@ -import { useContext } from 'react'; -import { GlobalError } from '../error/GlobalError'; import { components } from '../service/apiSchema.generated'; -import { ProjectContext } from './ProjectProvider'; +import { useProjectContext } from './ProjectContext'; export const useProject = (): components['schemas']['ProjectModel'] => { - const { project } = useProjectContext(); - return project; + const project = useProjectContext((c) => c.project); + return project!; }; export function useProjectContextOptional() { - return useContext(ProjectContext); + return useProjectContext((c) => c); } - -export const useProjectContext = () => { - const projectContext = useProjectContextOptional(); - if (!projectContext) { - throw new GlobalError( - 'Unexpected error', - 'No data in loadable? Did you use provider before using hook?' - ); - } - - return projectContext; -}; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 5eb382566b..75354fd031 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -102,6 +102,9 @@ export interface paths { /** Imports the data prepared in previous step */ put: operations["applyImport"]; }; + "/v2/projects/{projectId}/batch-jobs/{id}/cancel": { + put: operations["cancel"]; + }; "/v2/projects/{projectId}/translations/{translationId}/set-state/{state}": { put: operations["setTranslationState"]; }; @@ -109,7 +112,7 @@ export interface paths { put: operations["setState"]; }; "/v2/projects/{projectId}/translations/{translationId}/comments/{commentId}": { - get: operations["get_4"]; + get: operations["get_6"]; put: operations["update_2"]; delete: operations["delete_5"]; }; @@ -131,7 +134,7 @@ export interface paths { put: operations["leaveProject"]; }; "/v2/projects/{projectId}/languages/{languageId}": { - get: operations["get_6"]; + get: operations["get_8"]; put: operations["editLanguage"]; delete: operations["deleteLanguage_2"]; }; @@ -149,7 +152,7 @@ export interface paths { delete: operations["removeAvatar_1"]; }; "/v2/pats/{id}": { - get: operations["get_8"]; + get: operations["get_10"]; put: operations["update_4"]; delete: operations["delete_7"]; }; @@ -166,7 +169,7 @@ export interface paths { put: operations["setBasePermissions_1"]; }; "/v2/organizations/{id}": { - get: operations["get_10"]; + get: operations["get_12"]; put: operations["update_5"]; delete: operations["delete_8"]; }; @@ -253,6 +256,21 @@ export interface paths { post: operations["create_1"]; delete: operations["delete_3"]; }; + "/v2/projects/{projectId}/start-batch-job/translate": { + post: operations["translate"]; + }; + "/v2/projects/{projectId}/start-batch-job/set-translation-state": { + post: operations["setTranslationState_2"]; + }; + "/v2/projects/{projectId}/start-batch-job/delete-keys": { + post: operations["deleteKeys"]; + }; + "/v2/projects/{projectId}/start-batch-job/copy-translations": { + post: operations["copyTranslations"]; + }; + "/v2/projects/{projectId}/start-batch-job/clear-translations": { + post: operations["clearTranslations"]; + }; "/v2/projects/{projectId}/import": { /** Prepares provided files to import. */ post: operations["addFiles"]; @@ -376,6 +394,9 @@ export interface paths { "/v2/projects/{projectId}/activity": { get: operations["getActivity"]; }; + "/v2/projects/{projectId}/my-batch-jobs": { + get: operations["myList"]; + }; "/v2/projects/{projectId}/import/result/languages/{languageId}/translations": { /** Returns translations prepared to import. */ get: operations["getImportTranslations"]; @@ -398,6 +419,15 @@ export interface paths { /** Returns all existing and imported namespaces */ get: operations["getAllNamespaces_2"]; }; + "/v2/projects/{projectId}/current-batch-jobs": { + get: operations["currentJobs"]; + }; + "/v2/projects/{projectId}/batch-jobs/{id}": { + get: operations["get_4"]; + }; + "/v2/projects/{projectId}/batch-jobs": { + get: operations["list"]; + }; "/v2/projects/{projectId}/translations/{translationId}/history": { get: operations["getTranslationHistory"]; }; @@ -432,7 +462,7 @@ export interface paths { get: operations["getCurrent"]; }; "/v2/organizations/{slug}": { - get: operations["get_9"]; + get: operations["get_11"]; }; "/v2/organizations/{slug}/projects": { get: operations["getAllProjects"]; @@ -466,7 +496,7 @@ export interface paths { get: operations["getInfo_3"]; }; "/v2/api-keys/{keyId}": { - get: operations["get_11"]; + get: operations["get_13"]; }; "/v2/api-keys/current": { get: operations["getCurrent_1"]; @@ -581,14 +611,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 @@ -628,7 +650,18 @@ export interface components { | "keys.view" | "keys.delete" | "keys.create" + | "batch-jobs.view" + | "batch-jobs.cancel" + | "batch-auto-translate" )[]; + /** + * @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 */ @@ -685,6 +718,9 @@ export interface components { | "keys.view" | "keys.delete" | "keys.create" + | "batch-jobs.view" + | "batch-jobs.cancel" + | "batch-auto-translate" )[]; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; @@ -1156,15 +1192,15 @@ export interface components { token: string; /** Format: int64 */ id: number; - description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; - /** Format: int64 */ - expiresAt?: number; + description: string; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + expiresAt?: number; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -1297,17 +1333,17 @@ export interface components { key: string; /** Format: int64 */ id: number; - userFullName?: string; - projectName: string; - description: string; username?: string; + description: string; + /** Format: int64 */ + lastUsedAt?: number; /** Format: int64 */ projectId: number; /** Format: int64 */ expiresAt?: number; - /** Format: int64 */ - lastUsedAt?: number; scopes: string[]; + projectName: string; + userFullName?: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -1534,6 +1570,85 @@ export interface components { screenshotUploadedImageIds?: number[]; screenshots?: components["schemas"]["KeyScreenshotDto"][]; }; + BatchTranslateRequest: { + keyIds: number[]; + targetLanguageIds: number[]; + useMachineTranslation: boolean; + useTranslationMemory: boolean; + /** @description Translation service provider to use for translation. When null, Tolgee will use the primary service. */ + service?: "GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE"; + }; + BatchJobModel: { + /** + * Format: int64 + * @description Batch job id + */ + id: number; + /** @description Status of the batch job */ + status: "PENDING" | "RUNNING" | "SUCCESS" | "FAILED" | "CANCELLED"; + /** @description Type of the batch job */ + type: + | "AUTO_TRANSLATION" + | "DELETE_KEYS" + | "SET_TRANSLATIONS_STATE" + | "CLEAR_TRANSLATIONS" + | "COPY_TRANSLATIONS"; + /** + * Format: int32 + * @description Total items, that have been processed so far + */ + progress: number; + /** + * Format: int32 + * @description Total items + */ + totalItems: number; + author?: components["schemas"]["SimpleUserAccountModel"]; + /** + * Format: int64 + * @description The time when the job created + */ + createdAt: number; + /** + * Format: int64 + * @description The time when the job was last updated (status change) + */ + updatedAt: number; + /** + * Format: int64 + * @description The activity revision id, that stores the activity details of the job + */ + activityRevisionId?: number; + /** @description If the job failed, this is the error message */ + errorMessage?: string; + }; + /** @description The user who started the job */ + SimpleUserAccountModel: { + /** Format: int64 */ + id: number; + username: string; + name?: string; + avatar?: components["schemas"]["Avatar"]; + deleted: boolean; + }; + SetTranslationsStateStateRequest: { + keyIds: number[]; + languageIds: number[]; + state: "UNTRANSLATED" | "TRANSLATED" | "REVIEWED"; + }; + DeleteKeysRequest: { + keyIds: number[]; + }; + CopyTranslationRequest: { + keyIds: number[]; + /** Format: int64 */ + sourceLanguageId: number; + targetLanguageIds: number[]; + }; + ClearTranslationsRequest: { + keyIds: number[]; + languageIds: number[]; + }; ErrorResponseBody: { code: string; params?: { [key: string]: unknown }[]; @@ -1782,7 +1897,10 @@ export interface components { | "translations.state-edit" | "keys.view" | "keys.delete" - | "keys.create"; + | "keys.create" + | "batch-jobs.view" + | "batch-jobs.cancel" + | "batch-auto-translate"; requires: components["schemas"]["HierarchyItem"][]; }; AuthMethodsDTO: { @@ -1840,7 +1958,6 @@ export interface components { name: string; /** Format: int64 */ id: number; - basePermissions: components["schemas"]["PermissionModel"]; /** @example This is a beautiful organization full of beautiful and clever people */ description?: string; /** @@ -1852,6 +1969,7 @@ export interface components { avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; + basePermissions: components["schemas"]["PermissionModel"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -1952,18 +2070,18 @@ export interface components { name: string; /** Format: int64 */ id: number; - baseTranslation?: string; namespace?: string; translation?: string; + baseTranslation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; name: string; /** Format: int64 */ id: number; - baseTranslation?: string; namespace?: string; translation?: string; + baseTranslation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -2052,7 +2170,10 @@ export interface components { | "DELETE_LANGUAGE" | "CREATE_PROJECT" | "EDIT_PROJECT" - | "NAMESPACE_EDIT"; + | "NAMESPACE_EDIT" + | "BATCH_AUTO_TRANSLATE" + | "CLEAR_TRANSLATIONS" + | "COPY_TRANSLATIONS"; author?: components["schemas"]["ProjectActivityAuthorModel"]; modifiedEntities?: { [key: string]: components["schemas"]["ModifiedEntityModel"][]; @@ -2064,6 +2185,12 @@ export interface components { old?: { [key: string]: unknown }; new?: { [key: string]: unknown }; }; + PagedModelBatchJobModel: { + _embedded?: { + batchJobs?: components["schemas"]["BatchJobModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; ImportTranslationModel: { /** Format: int64 */ id: number; @@ -2084,6 +2211,7 @@ export interface components { page?: components["schemas"]["PageMetadata"]; }; EntityModelImportFileIssueView: { + params: components["schemas"]["ImportFileIssueParamView"][]; /** Format: int64 */ id: number; type: @@ -2096,7 +2224,6 @@ export interface components { | "ID_ATTRIBUTE_NOT_PROVIDED" | "TARGET_NOT_PROVIDED" | "TRANSLATION_TOO_LONG"; - params: components["schemas"]["ImportFileIssueParamView"][]; }; ImportFileIssueParamView: { value?: string; @@ -2130,6 +2257,11 @@ export interface components { /** @example homepage */ name: string; }; + CollectionModelBatchJobModel: { + _embedded?: { + batchJobs?: components["schemas"]["BatchJobModel"][]; + }; + }; PagedModelTranslationCommentModel: { _embedded?: { translationComments?: components["schemas"]["TranslationCommentModel"][]; @@ -2142,15 +2274,6 @@ export interface components { }; page?: components["schemas"]["PageMetadata"]; }; - /** @description Author of the change */ - SimpleUserAccountModel: { - /** Format: int64 */ - id: number; - username: string; - name?: string; - avatar?: components["schemas"]["Avatar"]; - deleted: boolean; - }; TranslationHistoryModel: { /** @description Modified fields */ modifications?: { @@ -2396,15 +2519,15 @@ export interface components { user: components["schemas"]["SimpleUserAccountModel"]; /** Format: int64 */ id: number; - description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; - /** Format: int64 */ - expiresAt?: number; + description: string; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + expiresAt?: number; }; OrganizationRequestParamsDto: { filterCurrentUserOwner: boolean; @@ -2523,17 +2646,17 @@ export interface components { permittedLanguageIds?: number[]; /** Format: int64 */ id: number; - userFullName?: string; - projectName: string; - description: string; username?: string; + description: string; + /** Format: int64 */ + lastUsedAt?: number; /** Format: int64 */ projectId: number; /** Format: int64 */ expiresAt?: number; - /** Format: int64 */ - lastUsedAt?: number; scopes: string[]; + projectName: string; + userFullName?: string; }; PagedModelUserAccountModel: { _embedded?: { @@ -3554,6 +3677,30 @@ export interface operations { }; }; }; + cancel: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; setTranslationState: { parameters: { path: { @@ -3612,7 +3759,7 @@ export interface operations { }; }; }; - get_4: { + get_6: { parameters: { path: { translationId: number; @@ -3947,7 +4094,7 @@ export interface operations { }; }; }; - get_6: { + get_8: { parameters: { path: { languageId: number; @@ -4133,7 +4280,7 @@ export interface operations { }; }; }; - get_8: { + get_10: { parameters: { path: { id: number; @@ -4333,7 +4480,7 @@ export interface operations { }; }; }; - get_10: { + get_12: { parameters: { path: { id: number; @@ -5266,6 +5413,166 @@ export interface operations { }; }; }; + translate: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["BatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchTranslateRequest"]; + }; + }; + }; + setTranslationState_2: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["BatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetTranslationsStateStateRequest"]; + }; + }; + }; + deleteKeys: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["BatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteKeysRequest"]; + }; + }; + }; + copyTranslations: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["BatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CopyTranslationRequest"]; + }; + }; + }; + clearTranslations: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["BatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ClearTranslationsRequest"]; + }; + }; + }; /** Prepares provided files to import. */ addFiles: { parameters: { @@ -6264,6 +6571,9 @@ export interface operations { | "keys.view" | "keys.delete" | "keys.create" + | "batch-jobs.view" + | "batch-jobs.cancel" + | "batch-auto-translate" )[]; }; }; @@ -6645,6 +6955,41 @@ export interface operations { }; }; }; + myList: { + 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[]; + }; + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["PagedModelBatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; /** Returns translations prepared to import. */ getImportTranslations: { parameters: { @@ -6843,6 +7188,96 @@ export interface operations { }; }; }; + currentJobs: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["CollectionModelBatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + get_4: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["BatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + 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[]; + }; + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["PagedModelBatchJobModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; getTranslationHistory: { parameters: { path: { @@ -7211,7 +7646,7 @@ export interface operations { }; }; }; - get_9: { + get_11: { parameters: { path: { slug: string; @@ -7545,7 +7980,7 @@ export interface operations { }; }; }; - get_11: { + get_13: { parameters: { path: { keyId: number; diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts index a25720f7bc..15fe3ecdf4 100644 --- a/webapp/src/service/billingApiSchema.generated.ts +++ b/webapp/src/service/billingApiSchema.generated.ts @@ -19,16 +19,6 @@ export interface paths { "/v2/organizations/{organizationId}/billing/cancel-subscription": { put: operations["cancelSubscription"]; }; - "/v2/administration/billing/self-hosted-ee-plans/{planId}": { - get: operations["getPlan"]; - put: operations["updatePlan"]; - delete: operations["deletePlan"]; - }; - "/v2/administration/billing/cloud-plans/{planId}": { - get: operations["getPlan_1"]; - put: operations["updatePlan_1"]; - delete: operations["deletePlan_1"]; - }; "/v2/organizations/{organizationId}/billing/subscribe": { post: operations["subscribe"]; }; @@ -39,14 +29,6 @@ export interface paths { "/v2/organizations/{organizationId}/billing/buy-more-credits": { post: operations["getBuyMoreCreditsCheckoutSessionUrl"]; }; - "/v2/administration/billing/self-hosted-ee-plans": { - get: operations["getPlans_1"]; - post: operations["create"]; - }; - "/v2/administration/billing/cloud-plans": { - get: operations["getPlans_2"]; - post: operations["create_1"]; - }; "/v2/public/billing/plans": { get: operations["getPlans"]; }; @@ -88,18 +70,6 @@ export interface paths { "/v2/organizations/{organizationId}/billing/billing-info": { get: operations["getBillingInfo"]; }; - "/v2/administration/billing/stripe-products": { - get: operations["getStripeProducts"]; - }; - "/v2/administration/billing/self-hosted-ee-plans/{planId}/organizations": { - get: operations["getPlanOrganizations"]; - }; - "/v2/administration/billing/features": { - get: operations["getAllFeatures"]; - }; - "/v2/administration/billing/cloud-plans/{planId}/organizations": { - get: operations["getPlanOrganizations_1"]; - }; "/v2/organizations/{organizationId}/billing/self-hosted-ee/subscriptions/{subscriptionId}": { delete: operations["cancelEeSubscription"]; }; @@ -117,9 +87,13 @@ export interface components { }; Links: { [key: string]: components["schemas"]["Link"] }; PlanIncludedUsageModel: { + /** Format: int64 */ seats: number; + /** Format: int64 */ translationSlots: number; + /** Format: int64 */ translations: number; + /** Format: int64 */ mtCredits: number; }; PlanPricesModel: { @@ -130,6 +104,7 @@ export interface components { subscriptionYearly: number; }; SelfHostedEePlanModel: { + /** Format: int64 */ id: number; name: string; public: boolean; @@ -150,10 +125,14 @@ export interface components { hasYearlyPrice: 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: @@ -167,6 +146,7 @@ export interface components { estimatedCosts?: number; }; CloudPlanModel: { + /** Format: int64 */ id: number; name: string; free: boolean; @@ -186,20 +166,26 @@ export interface components { prices: components["schemas"]["PlanPricesModel"]; includedUsage: components["schemas"]["PlanIncludedUsageModel"]; hasYearlyPrice: boolean; - public: boolean; }; CloudSubscriptionModel: { + /** Format: int64 */ organizationId: number; plan: components["schemas"]["CloudPlanModel"]; + /** Format: int64 */ currentPeriodStart?: number; + /** Format: int64 */ currentPeriodEnd?: number; currentBillingPeriod?: "MONTHLY" | "YEARLY"; cancelAtPeriodEnd: boolean; estimatedCosts?: number; + /** Format: int64 */ createdAt: number; }; UpdateSubscriptionPrepareRequest: { - /** Id of the subscription plan */ + /** + * Format: int64 + * @description Id of the subscription plan + */ planId: number; period: "MONTHLY" | "YEARLY"; }; @@ -213,118 +199,15 @@ export interface components { total: number; amountDue: number; updateToken: string; + /** Format: int64 */ prorationDate: number; endingBalance: number; }; - PlanIncludedUsageRequest: { - seats: number; - translations: number; - mtCredits: number; - }; - PlanPricesRequest: { - perSeat: number; - perThousandTranslations?: number; - perThousandMtCredits?: number; - subscriptionMonthly: number; - subscriptionYearly: number; - }; - SelfHostedEePlanRequest: { - name: string; - free: boolean; - enabledFeatures: ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - )[]; - prices: components["schemas"]["PlanPricesRequest"]; - includedUsage: components["schemas"]["PlanIncludedUsageRequest"]; - public: boolean; - stripeProductId: string; - notAvailableBefore?: string; - availableUntil?: string; - usableUntil?: string; - forOrganizationIds: number[]; - }; - SelfHostedEePlanAdministrationModel: { - 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" - )[]; - prices: components["schemas"]["PlanPricesModel"]; - includedUsage: components["schemas"]["PlanIncludedUsageModel"]; - hasYearlyPrice: boolean; - stripeProductId: string; - forOrganizationIds: number[]; - }; - CloudPlanRequest: { - name: string; - free: boolean; - enabledFeatures: ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - )[]; - type: "PAY_AS_YOU_GO" | "FIXED" | "SLOTS_FIXED"; - prices: components["schemas"]["PlanPricesRequest"]; - includedUsage: components["schemas"]["PlanIncludedUsageRequest"]; - public: boolean; - stripeProductId: string; - notAvailableBefore?: string; - availableUntil?: string; - usableUntil?: string; - forOrganizationIds: number[]; - }; - CloudPlanAdministrationModel: { - id: number; - name: string; - free: boolean; - enabledFeatures: ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - )[]; - type: "PAY_AS_YOU_GO" | "FIXED" | "SLOTS_FIXED"; - prices: components["schemas"]["PlanPricesModel"]; - includedUsage: components["schemas"]["PlanIncludedUsageModel"]; - hasYearlyPrice: boolean; - public: boolean; - stripeProductId: string; - forOrganizationIds: number[]; - }; CloudSubscribeRequest: { - /** Id of the subscription plan */ + /** + * Format: int64 + * @description Id of the subscription plan + */ planId: number; period: "MONTHLY" | "YEARLY"; }; @@ -332,12 +215,17 @@ export interface components { url: string; }; SelfHostedEeSubscribeRequest: { - /** Id of the subscription plan */ + /** + * Format: int64 + * @description Id of the subscription plan + */ planId: number; period: "MONTHLY" | "YEARLY"; }; BuyMoreCreditsRequest: { + /** Format: int64 */ priceId: number; + /** Format: int64 */ amount: number; }; BuyMoreCreditsModel: { @@ -354,8 +242,10 @@ export interface components { }; }; MtCreditsPriceModel: { + /** Format: int64 */ id: number; price: number; + /** Format: int64 */ amount: number; }; AverageProportionalUsageItemModel: { @@ -366,13 +256,16 @@ export interface components { }; SumUsageItemModel: { total: number; + /** Format: int64 */ unusedQuantity: number; + /** Format: int64 */ usedQuantity: number; + /** Format: int64 */ usedQuantityOverPlan: number; }; UsageModel: { subscriptionPrice?: number; - /** Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */ + /** @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"]; @@ -385,21 +278,27 @@ export interface components { }; }; InvoiceModel: { + /** Format: int64 */ id: number; - /** The number on the invoice */ + /** @description The number on the invoice */ number: string; + /** Format: int64 */ createdAt: number; - /** The Total amount with tax */ + /** @description The Total amount with tax */ total: number; taxRatePercentage?: number; - /** Whether pdf is ready to download. If not, wait around few minutes until it's generated. */ + /** @description Whether pdf is ready to download. If not, wait around few minutes until it's generated. */ pdfReady: boolean; hasUsage: boolean; }; PageMetadata: { + /** Format: int64 */ size?: number; + /** Format: int64 */ totalElements?: number; + /** Format: int64 */ totalPages?: number; + /** Format: int64 */ number?: number; }; PagedModelInvoiceModel: { @@ -423,82 +322,6 @@ export interface components { vatNo?: string; email?: string; }; - CollectionModelStripeProductModel: { - _embedded?: { - stripeProductModels?: components["schemas"]["StripeProductModel"][]; - }; - }; - StripeProductModel: { - id: string; - name: string; - created: number; - }; - CollectionModelSelfHostedEePlanAdministrationModel: { - _embedded?: { - plans?: components["schemas"]["SelfHostedEePlanAdministrationModel"][]; - }; - }; - Avatar: { - large: string; - thumbnail: string; - }; - PagedModelSimpleOrganizationModel: { - _embedded?: { - organizations?: components["schemas"]["SimpleOrganizationModel"][]; - }; - page?: components["schemas"]["PageMetadata"]; - }; - PermissionModel: { - /** Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. */ - scopes: ( - | "translations.view" - | "translations.edit" - | "keys.edit" - | "screenshots.upload" - | "screenshots.delete" - | "screenshots.view" - | "activity.view" - | "languages.edit" - | "admin" - | "project.edit" - | "members.view" - | "members.edit" - | "translation-comments.add" - | "translation-comments.edit" - | "translation-comments.set-state" - | "translations.state-edit" - | "keys.view" - | "keys.delete" - | "keys.create" - )[]; - /** The user's permission type. This field is null if uses granular permissions */ - type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - */ - permittedLanguageIds?: number[]; - /** List of languages user can translate to. If null, all languages editing is permitted. */ - translateLanguageIds?: number[]; - /** List of languages user can view. If null, all languages view is permitted. */ - viewLanguageIds?: number[]; - /** List of languages user can change state to. If null, changing state of all language values is permitted. */ - stateChangeLanguageIds?: number[]; - }; - SimpleOrganizationModel: { - id: number; - name: string; - slug: string; - description?: string; - basePermissions: components["schemas"]["PermissionModel"]; - avatar?: components["schemas"]["Avatar"]; - }; - CollectionModelCloudPlanAdministrationModel: { - _embedded?: { - plans?: components["schemas"]["CloudPlanAdministrationModel"][]; - }; - }; Link: { href?: string; hreflang?: string; @@ -650,170 +473,6 @@ export interface operations { }; }; }; - getPlan: { - parameters: { - path: { - planId: number; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["SelfHostedEePlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - updatePlan: { - parameters: { - path: { - planId: number; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["SelfHostedEePlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SelfHostedEePlanRequest"]; - }; - }; - }; - deletePlan: { - parameters: { - path: { - planId: number; - }; - }; - responses: { - /** OK */ - 200: unknown; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - getPlan_1: { - parameters: { - path: { - planId: number; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["CloudPlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - updatePlan_1: { - parameters: { - path: { - planId: number; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["CloudPlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CloudPlanRequest"]; - }; - }; - }; - deletePlan_1: { - parameters: { - path: { - planId: number; - }; - }; - responses: { - /** OK */ - 200: unknown; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; subscribe: { parameters: { path: { @@ -937,104 +596,6 @@ export interface operations { }; }; }; - getPlans_1: { - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["CollectionModelSelfHostedEePlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - create: { - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["SelfHostedEePlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SelfHostedEePlanRequest"]; - }; - }; - }; - getPlans_2: { - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["CollectionModelCloudPlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - create_1: { - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["CloudPlanAdministrationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CloudPlanRequest"]; - }; - }; - }; getPlans: { responses: { /** OK */ @@ -1391,133 +952,6 @@ export interface operations { }; }; }; - getStripeProducts: { - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["CollectionModelStripeProductModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - getPlanOrganizations: { - parameters: { - path: { - planId: number; - }; - 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; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["PagedModelSimpleOrganizationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - getAllFeatures: { - responses: { - /** OK */ - 200: { - content: { - "*/*": ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - )[]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; - getPlanOrganizations_1: { - parameters: { - path: { - planId: number; - }; - 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; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "*/*": components["schemas"]["PagedModelSimpleOrganizationModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - }; cancelEeSubscription: { parameters: { path: { diff --git a/webapp/src/translationTools/useBatchOperationStatusTranslate.ts b/webapp/src/translationTools/useBatchOperationStatusTranslate.ts new file mode 100644 index 0000000000..041d63eaaa --- /dev/null +++ b/webapp/src/translationTools/useBatchOperationStatusTranslate.ts @@ -0,0 +1,23 @@ +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; + +type BatchJobStatus = components['schemas']['BatchJobModel']['status']; + +export function useBatchOperationStatusTranslate() { + const { t } = useTranslate(); + + return (status: BatchJobStatus) => { + switch (status) { + case 'PENDING': + return t('batch_operation_status_pending'); + case 'RUNNING': + return t('batch_operation_status_running'); + case 'SUCCESS': + return t('batch_operation_status_success'); + case 'FAILED': + return t('batch_operation_status_failed'); + case 'CANCELLED': + return t('batch_operation_status_cancelled'); + } + }; +} diff --git a/webapp/src/translationTools/useBatchOperationTypeTranslation.ts b/webapp/src/translationTools/useBatchOperationTypeTranslation.ts new file mode 100644 index 0000000000..e49d8b1a40 --- /dev/null +++ b/webapp/src/translationTools/useBatchOperationTypeTranslation.ts @@ -0,0 +1,25 @@ +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; + +type BatchJobType = components['schemas']['BatchJobModel']['type']; + +export function useBatchOperationTypeTranslate() { + const { t } = useTranslate(); + + return (type: BatchJobType) => { + switch (type) { + case 'DELETE_KEYS': + return t('batch_operation_type_delete_keys'); + case 'AUTO_TRANSLATION': + return t('batch_operation_type_translation'); + case 'COPY_TRANSLATIONS': + return t('batch_operation_type_copy_translations'); + case 'CLEAR_TRANSLATIONS': + return t('batch_operation_type_clear_translations'); + case 'SET_TRANSLATIONS_STATE': + return t('batch_operation_set_translations_state'); + default: + return type; + } + }; +} diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index ec0d39ed15..61a4a8231d 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -73,6 +73,9 @@ export function useErrorTranslation() { return t('seats_spending_limit_exceeded'); case 'mt_service_not_enabled': return t('mt_service_not_enabled'); + case 'out_of_credits': + return t('out_of_credits'); + // from 'ApiHttpService.tsx' case 'authentication_cancelled': return t('authentication_cancelled'); diff --git a/webapp/src/views/projects/BaseProjectView.tsx b/webapp/src/views/projects/BaseProjectView.tsx index 77b0916dd6..9314a14cf9 100644 --- a/webapp/src/views/projects/BaseProjectView.tsx +++ b/webapp/src/views/projects/BaseProjectView.tsx @@ -1,3 +1,4 @@ +import { Box } from '@mui/material'; import { useHistory } from 'react-router-dom'; import { Usage } from 'tg.component/billing/Usage'; import { BaseView, BaseViewProps } from 'tg.component/layout/BaseView'; @@ -6,6 +7,7 @@ import { SmallProjectAvatar } from 'tg.component/navigation/SmallProjectAvatar'; import { OrganizationSwitch } from 'tg.component/organizationSwitch/OrganizationSwitch'; import { LINKS, PARAMS } from 'tg.constants/links'; import { useProject } from 'tg.hooks/useProject'; +import { BatchOperationsSummary } from './translations/BatchOperations/OperationsSummary/OperationsSummary'; type Props = BaseViewProps; @@ -35,7 +37,12 @@ export const BaseProjectView: React.FC = ({ } + navigationRight={ + + + + + } /> ); }; diff --git a/webapp/src/views/projects/ProjectRouter.tsx b/webapp/src/views/projects/ProjectRouter.tsx index a9d9da5d30..c9e4dbd6f9 100644 --- a/webapp/src/views/projects/ProjectRouter.tsx +++ b/webapp/src/views/projects/ProjectRouter.tsx @@ -2,7 +2,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom'; import { PrivateRoute } from 'tg.component/common/PrivateRoute'; import { LINKS, PARAMS } from 'tg.constants/links'; -import { ProjectProvider } from 'tg.hooks/ProjectProvider'; +import { ProjectContext } from 'tg.hooks/ProjectContext'; import { ProjectPage } from './ProjectPage'; import { ExportView } from './export/ExportView'; @@ -35,7 +35,7 @@ export const ProjectRouter = () => { return ( - + }> @@ -86,7 +86,7 @@ export const ProjectRouter = () => { - + ); }; diff --git a/webapp/src/views/projects/WebsocketPreview.tsx b/webapp/src/views/projects/WebsocketPreview.tsx index 36b5f03d68..0dd0a9a5cf 100644 --- a/webapp/src/views/projects/WebsocketPreview.tsx +++ b/webapp/src/views/projects/WebsocketPreview.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'tg.store/index'; import { BaseView } from 'tg.component/layout/BaseView'; -import { WebsocketClient } from '../../websocket-client/WebsocketClient'; +import { WebsocketClient } from 'tg.websocket-client/WebsocketClient'; export const WebsocketPreview = () => { const config = useConfig(); diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx new file mode 100644 index 0000000000..6a794f5bb0 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx @@ -0,0 +1,193 @@ +import { styled, IconButton, Tooltip, Checkbox, Box } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { useState } from 'react'; +import { useGlobalLoading } from 'tg.component/GlobalLoading'; +import { useProjectActions } from 'tg.hooks/ProjectContext'; + +import { + useTranslationsActions, + useTranslationsSelector, +} from '../context/TranslationsContext'; +import { BatchSelect } from './BatchSelect'; +import { OperationAddTags } from './OperationAddTags'; +import { OperationChangeNamespace } from './OperationChangeNamespace'; +import { OperationClearTranslations } from './OperationClearTranslations'; +import { OperationCopyTranslations } from './OperationCopyTranslations'; +import { OperationDelete } from './OperationDelete'; +import { OperationMarkAsReviewed } from './OperationMarkAsReviewed'; +import { OperationMarkAsTranslated } from './OperationMarkAsTranslated'; +import { OperationRemoveTags } from './OperationRemoveTags'; +import { BatchOperationDialog } from './OperationsSummary/BatchOperationDialog'; +import { OperationAutoTranslate } from './OperationAutoTranslate'; +import { BatchActions, BatchJobModel } from './types'; + +const StyledContainer = styled('div')` + position: absolute; + display: flex; + bottom: 0px; + right: 0px; + width: 100%; + transition: all 300ms ease-in-out; + flex-shrink: 1; + + & .MuiInputBase-colorPrimary { + background: ${({ theme }) => theme.palette.background.default}; + } +`; + +const StyledContent = styled('div')` + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: start; + box-sizing: border-box; + position: relative; + transition: background-color 300ms ease-in-out, visibility 0ms; + padding: ${({ theme }) => theme.spacing(0.5, 2, 0.5, 2)}; + pointer-events: all; + border-radius: 6px; + background-color: ${({ theme }) => theme.palette.emphasis[200]}; + -webkit-box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); +`; + +const StyledBase = styled('div')` + display: flex; + flex-wrap: wrap; + gap: 10px; +`; + +const StyledToggleAllButton = styled(IconButton)` + display: flex; + flex-shrink: 1; + width: 38px; + height: 38px; + margin-left: 3px; +`; + +const StyledItem = styled(Box)` + height: 40px; + display: flex; + align-items: center; + white-space: nowrap; +`; + +type Props = { + open: boolean; + onClose: () => void; +}; + +export const BatchOperations = ({ open, onClose }: Props) => { + const { t } = useTranslate(); + const selection = useTranslationsSelector((c) => c.selection); + const totalCount = useTranslationsSelector((c) => c.translationsTotal || 0); + const isLoading = useTranslationsSelector((c) => c.isLoadingAllIds); + const isDeleting = useTranslationsSelector((c) => c.isDeleting); + const { selectAll, selectionClear, refetchTranslations } = + useTranslationsActions(); + const { refetchBatchJobs } = useProjectActions(); + + const allSelected = totalCount === selection.length; + const somethingSelected = !allSelected && Boolean(selection.length); + const [operation, setOperation] = useState(); + const [runningOperation, setRunningOperation] = useState(); + + const handleToggleSelectAll = () => { + if (!allSelected) { + selectAll(); + } else { + selectionClear(); + } + }; + + const sharedProps = { + disabled: !selection.length, + onStart: (operation: BatchJobModel) => { + setRunningOperation(operation); + refetchBatchJobs(); + }, + }; + + function pickOperation() { + switch (operation) { + case 'delete': + return ; + case 'auto_translate': + return ; + case 'mark_as_translated': + return ; + case 'mark_as_reviewed': + return ; + case 'add_tags': + return ; + case 'remove_tags': + return ; + case 'change_namespace': + return ; + case 'copy_translations': + return ; + case 'clear_translations': + return ; + } + } + + function onFinished() { + refetchTranslations(); + selectionClear(); + setOperation(undefined); + onClose(); + } + + useGlobalLoading(isLoading); + + return ( + <> + {open && ( + + + + + + + + + + + {`${selection.length} / ${totalCount}`} + + + + + {pickOperation()} + + + )} + {runningOperation && ( + setRunningOperation(undefined)} + onFinished={onFinished} + /> + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx new file mode 100644 index 0000000000..43b44a6569 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx @@ -0,0 +1,67 @@ +import { Alert, Box, Button, Portal } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { useEffect, useState } from 'react'; +import { useProjectContext } from 'tg.hooks/ProjectContext'; +import { + useTranslationsActions, + useTranslationsSelector, +} from '../context/TranslationsContext'; +import { END_STATUSES } from './OperationsSummary/utils'; + +export const BatchOperationsChangeIndicator = () => { + const { t } = useTranslate(); + const { refetchTranslations } = useTranslationsActions(); + const lastJob = useProjectContext((c) => { + return c.batchOperations + ? c.batchOperations.find((o) => END_STATUSES.includes(o.status))?.id + : 'not-loaded'; + }); + const translations = useTranslationsSelector((c) => c.translations); + const translationsFetching = useTranslationsSelector((c) => c.isFetching); + + const [previousLast, setPreviousLast] = useState(lastJob); + const [dataChanged, setDataChanged] = useState(false); + + function handleRefetch() { + refetchTranslations(); + } + + // check when job is finished + useEffect(() => { + if ( + previousLast !== 'not-loaded' && + lastJob !== undefined && + lastJob !== previousLast + ) { + setDataChanged(true); + } + setPreviousLast(lastJob); + }, [lastJob]); + + // reset outdated status when data are updated + useEffect(() => { + setDataChanged(false); + }, [translations, translationsFetching]); + + return ( + <> + {dataChanged && ( + + + + {t('batch_operations_refresh_button')} + + } + > + {t('batch_operations_outdated_message')} + + + + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx new file mode 100644 index 0000000000..360f465c93 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx @@ -0,0 +1,59 @@ +import { Autocomplete, ListItem, styled, TextField } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { BatchActions } from './types'; + +const StyledSeparator = styled('div')` + width: 100%; + border: 1px solid ${({ theme }) => theme.palette.divider}; + border-width: 1px 0px 0px 0px; +`; + +type Props = { + value: BatchActions | undefined; + onChange: (value: BatchActions | undefined) => void; +}; + +export const BatchSelect = ({ value, onChange }: Props) => { + const { t } = useTranslate(); + + const options: { id: BatchActions; label: string; divider?: boolean }[] = [ + { id: 'auto_translate', label: t('batch_operations_translate') }, + { + id: 'mark_as_translated', + label: t('batch_operations_mark_as_translated'), + }, + { id: 'mark_as_reviewed', label: t('batch_operations_mark_as_reviewed') }, + { id: 'copy_translations', label: t('batch_operations_copy_translations') }, + { + id: 'clear_translations', + label: t('batch_operation_clear_translations'), + }, + { id: 'add_tags', label: t('batch_operations_add_tags'), divider: true }, + { id: 'remove_tags', label: t('batch_operations_remove_tags') }, + { id: 'change_namespace', label: t('batch_operations_change_namespace') }, + { id: 'delete', label: t('batch_operations_delete') }, + ]; + + return ( + o.id === value) || null} + onChange={(_, value) => { + onChange(value?.id); + }} + renderOption={(props, o) => ( + <> + {o.divider && } + + {o.label} + + + )} + options={options} + renderInput={(params) => ( + + )} + size="small" + /> + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationAddTags.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationAddTags.tsx new file mode 100644 index 0000000000..5d8ea623a7 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationAddTags.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box, styled } from '@mui/material'; + +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { OperationProps } from './types'; +import { Tag } from '../Tags/Tag'; +import { TagInput } from '../Tags/TagInput'; +import { useTranslate } from '@tolgee/react'; + +const StyledTags = styled('div')` + display: flex; + flex-wrap: wrap; + align-items: center; + overflow: hidden; + gap: 4px; + margin: 6px 6px; + position: relative; + max-width: 450px; +`; + +const StyledTag = styled(Tag)` + border-color: ${({ theme }) => theme.palette.success.main}; +`; + +type Props = OperationProps; + +export const OperationAddTags = ({ disabled, onStart }: Props) => { + const { t } = useTranslate(); + // const project = useProject(); + + // const selection = useTranslationsSelector((c) => c.selection); + + const [tags, setTags] = useState([]); + + function handleAddTag(tag: string) { + if (!tags.includes(tag)) { + setTags([...tags, tag]); + } + } + + function handleDelete(tag: string) { + setTags((tags) => tags.filter((t) => t !== tag)); + } + + // const batchLoadable = useApiMutation({ + // url: '/v2/projects/{projectId}/start-batch-job/translate', + // method: 'post', + // }); + + // function handleSubmit() { + // batchLoadable.mutate( + // { + // path: { projectId: project.id }, + // content: { + // 'application/json': { + // keyIds: selection, + // targetLanguageIds: allLanguages + // ?.filter((l) => selectedLangs?.includes(l.tag)) + // .map((l) => l.id), + // useMachineTranslation: true, + // useTranslationMemory: false, + // service: undefined, + // }, + // }, + // }, + // { + // onSuccess(data) { + // onStart(data); + // }, + // } + // ); + // } + + return ( + + + {tags.map((tag) => ( + handleDelete(tag)} /> + ))} + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationAutoTranslate.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationAutoTranslate.tsx new file mode 100644 index 0000000000..2dc9b25fc4 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationAutoTranslate.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box } from '@mui/material'; + +import { LanguagesSelect } from 'tg.component/common/form/LanguagesSelect/LanguagesSelect'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationProps } from './types'; +import { useTranslate } from '@tolgee/react'; + +type Props = OperationProps; + +export const OperationAutoTranslate = ({ disabled, onStart }: Props) => { + const project = useProject(); + const { t } = useTranslate(); + const allLanguages = useTranslationsSelector((c) => c.languages) || []; + const selection = useTranslationsSelector((c) => c.selection); + + const languages = allLanguages.filter((l) => !l.base); + + const [selectedLangs, setSelectedLangs] = useState([]); + + const batchLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/start-batch-job/translate', + method: 'post', + }); + + function handleSubmit() { + batchLoadable.mutate( + { + path: { projectId: project.id }, + content: { + 'application/json': { + keyIds: selection, + targetLanguageIds: allLanguages + ?.filter((l) => selectedLangs?.includes(l.tag)) + .map((l) => l.id), + useMachineTranslation: true, + useTranslationMemory: false, + service: undefined, + }, + }, + }, + { + onSuccess(data) { + onStart(data); + }, + } + ); + } + + return ( + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationChangeNamespace.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationChangeNamespace.tsx new file mode 100644 index 0000000000..ba077f57df --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationChangeNamespace.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box } from '@mui/material'; + +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { OperationProps } from './types'; +import { NamespaceSelector } from 'tg.component/NamespaceSelector/NamespaceSelector'; + +type Props = OperationProps; + +export const OperationChangeNamespace = ({ disabled, onStart }: Props) => { + // const project = useProject(); + + // const selection = useTranslationsSelector((c) => c.selection); + + const [namespace, setNamespace] = useState(); + + // const batchLoadable = useApiMutation({ + // url: '/v2/projects/{projectId}/start-batch-job/translate', + // method: 'post', + // }); + + // function handleSubmit() { + // batchLoadable.mutate( + // { + // path: { projectId: project.id }, + // content: { + // 'application/json': { + // keyIds: selection, + // targetLanguageIds: allLanguages + // ?.filter((l) => selectedLangs?.includes(l.tag)) + // .map((l) => l.id), + // useMachineTranslation: true, + // useTranslationMemory: false, + // service: undefined, + // }, + // }, + // }, + // { + // onSuccess(data) { + // onStart(data); + // }, + // } + // ); + // } + + return ( + + setNamespace(value)} + SearchSelectProps={{ SelectProps: { sx: { minWidth: 200 } } }} + /> + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationClearTranslations.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationClearTranslations.tsx new file mode 100644 index 0000000000..ca959a6678 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationClearTranslations.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box } from '@mui/material'; + +import { LanguagesSelect } from 'tg.component/common/form/LanguagesSelect/LanguagesSelect'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationProps } from './types'; +import { useTranslate } from '@tolgee/react'; + +type Props = OperationProps; + +export const OperationClearTranslations = ({ disabled, onStart }: Props) => { + const project = useProject(); + const { t } = useTranslate(); + const allLanguages = useTranslationsSelector((c) => c.languages) || []; + const selection = useTranslationsSelector((c) => c.selection); + + const [selectedLangs, setSelectedLangs] = useState([]); + + const batchLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/start-batch-job/clear-translations', + method: 'post', + }); + + function handleSubmit() { + batchLoadable.mutate( + { + path: { projectId: project.id }, + content: { + 'application/json': { + keyIds: selection, + languageIds: allLanguages + ?.filter((l) => selectedLangs?.includes(l.tag)) + .map((l) => l.id), + }, + }, + }, + { + onSuccess(data) { + onStart(data); + }, + } + ); + } + + return ( + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationCopyTranslations.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationCopyTranslations.tsx new file mode 100644 index 0000000000..ef47568acd --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationCopyTranslations.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box, Select, MenuItem, FormControl, InputLabel } from '@mui/material'; + +import { LanguagesSelect } from 'tg.component/common/form/LanguagesSelect/LanguagesSelect'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationProps } from './types'; +import { useTranslate } from '@tolgee/react'; + +type Props = OperationProps; + +export const OperationCopyTranslations = ({ disabled, onStart }: Props) => { + const project = useProject(); + const allLanguages = useTranslationsSelector((c) => c.languages) || []; + const selection = useTranslationsSelector((c) => c.selection); + const { t } = useTranslate(); + + const [sourceLanguage, setSourceLanguage] = useState(null); + const [selectedLangs, setSelectedLangs] = useState([]); + + function handleChangeSource(source: string | null) { + setSourceLanguage(source); + setSelectedLangs((langs) => langs?.filter((l) => l !== source)); + } + + const batchLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/start-batch-job/copy-translations', + method: 'post', + }); + + function handleSubmit() { + batchLoadable.mutate( + { + path: { projectId: project.id }, + content: { + 'application/json': { + keyIds: selection, + sourceLanguageId: allLanguages!.find( + (l) => l.tag === sourceLanguage + )!.id, + targetLanguageIds: allLanguages + ?.filter((l) => selectedLangs?.includes(l.tag)) + .map((l) => l.id), + }, + }, + }, + { + onSuccess(data) { + onStart(data); + }, + } + ); + } + + return ( + + + {t('batch_operations_copy_from_label')} + + {!sourceLanguage && ( + + {t('batch_operations_copy_source_language_placeholder')} + + )} + + + + + {t('batch_operations_copy_to_label')} + l.tag === sourceLanguage) + .map((l) => l.id)} + context="batch-operations" + placeholder={t('batch_operations_select_languages_placeholder')} + /> + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationDelete.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationDelete.tsx new file mode 100644 index 0000000000..6fc9604386 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationDelete.tsx @@ -0,0 +1,61 @@ +import { ChevronRight } from '@mui/icons-material'; +import { useTranslate } from '@tolgee/react'; + +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { confirmation } from 'tg.hooks/confirmation'; +import { useProject } from 'tg.hooks/useProject'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationProps } from './types'; + +type Props = OperationProps; +export const OperationDelete = ({ disabled, onStart }: Props) => { + const { t } = useTranslate(); + const selection = useTranslationsSelector((c) => c.selection); + const project = useProject(); + const batchLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/start-batch-job/delete-keys', + method: 'post', + }); + + function handleSubmit() { + confirmation({ + title: t('translations_delete_selected'), + message: t('translations_key_delete_confirmation_text', { + count: String(selection.length), + }), + onConfirm() { + batchLoadable.mutate( + { + path: { projectId: project.id }, + content: { + 'application/json': { + keyIds: selection, + }, + }, + }, + { + onSuccess(data) { + onStart(data); + }, + } + ); + }, + }); + } + + return ( + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationMarkAsReviewed.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationMarkAsReviewed.tsx new file mode 100644 index 0000000000..94341d5c72 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationMarkAsReviewed.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box } from '@mui/material'; + +import { LanguagesSelect } from 'tg.component/common/form/LanguagesSelect/LanguagesSelect'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { useProject } from 'tg.hooks/useProject'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationProps } from './types'; +import { useTranslate } from '@tolgee/react'; + +type Props = OperationProps; + +export const OperationMarkAsReviewed = ({ disabled, onStart }: Props) => { + const { t } = useTranslate(); + const project = useProject(); + const allLanguages = useTranslationsSelector((c) => c.languages) || []; + + const selection = useTranslationsSelector((c) => c.selection); + + const [selectedLangs, setSelectedLangs] = useState([]); + + const batchLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/start-batch-job/set-translation-state', + method: 'post', + }); + + function handleSubmit() { + batchLoadable.mutate( + { + path: { projectId: project.id }, + content: { + 'application/json': { + keyIds: selection, + languageIds: allLanguages + ?.filter((l) => selectedLangs?.includes(l.tag)) + .map((l) => l.id), + state: 'REVIEWED', + }, + }, + }, + { + onSuccess(data) { + onStart(data); + }, + } + ); + } + + return ( + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationMarkAsTranslated.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationMarkAsTranslated.tsx new file mode 100644 index 0000000000..8726e44289 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationMarkAsTranslated.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box } from '@mui/material'; + +import { LanguagesSelect } from 'tg.component/common/form/LanguagesSelect/LanguagesSelect'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationProps } from './types'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { useTranslate } from '@tolgee/react'; + +type Props = OperationProps; + +export const OperationMarkAsTranslated = ({ disabled, onStart }: Props) => { + const { t } = useTranslate(); + const project = useProject(); + const allLanguages = useTranslationsSelector((c) => c.languages) || []; + const selection = useTranslationsSelector((c) => c.selection); + + const [selectedLangs, setSelectedLangs] = useState([]); + + const batchLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/start-batch-job/set-translation-state', + method: 'post', + }); + + function handleSubmit() { + batchLoadable.mutate( + { + path: { projectId: project.id }, + content: { + 'application/json': { + keyIds: selection, + languageIds: allLanguages + ?.filter((l) => selectedLangs?.includes(l.tag)) + .map((l) => l.id), + state: 'TRANSLATED', + }, + }, + }, + { + onSuccess(data) { + onStart(data); + }, + } + ); + } + + return ( + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationRemoveTags.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationRemoveTags.tsx new file mode 100644 index 0000000000..ae616e38a2 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationRemoveTags.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { ChevronRight } from '@mui/icons-material'; +import { Box, styled } from '@mui/material'; + +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { OperationProps } from './types'; +import { Tag } from '../Tags/Tag'; +import { TagInput } from '../Tags/TagInput'; +import { useTranslate } from '@tolgee/react'; + +const StyledTags = styled('div')` + display: flex; + flex-wrap: wrap; + align-items: center; + overflow: hidden; + gap: 4px; + margin: 6px 6px; + position: relative; + max-width: 450px; +`; + +const StyledTag = styled(Tag)` + border-color: ${({ theme }) => theme.palette.error.main}; +`; + +type Props = OperationProps; + +export const OperationRemoveTags = ({ disabled, onStart }: Props) => { + const { t } = useTranslate(); + // const project = useProject(); + + // const selection = useTranslationsSelector((c) => c.selection); + + const [tags, setTags] = useState([]); + + function handleAddTag(tag: string) { + if (!tags.includes(tag)) { + setTags([...tags, tag]); + } + } + + function handleDelete(tag: string) { + setTags((tags) => tags.filter((t) => t !== tag)); + } + + // const batchTranslate = useApiMutation({ + // url: '/v2/projects/{projectId}/start-batch-job/translate', + // method: 'post', + // }); + + // function handleSubmit() { + // batchTranslate.mutate( + // { + // path: { projectId: project.id }, + // content: { + // 'application/json': { + // keyIds: selection, + // targetLanguageIds: allLanguages + // ?.filter((l) => selectedLangs?.includes(l.tag)) + // .map((l) => l.id), + // useMachineTranslation: true, + // useTranslationMemory: false, + // service: undefined, + // }, + // }, + // }, + // { + // onSuccess(data) { + // onStart(data); + // }, + // } + // ); + // } + + return ( + + + {tags.map((tag) => ( + handleDelete(tag)} /> + ))} + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchIndicator.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchIndicator.tsx new file mode 100644 index 0000000000..1300df1aea --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchIndicator.tsx @@ -0,0 +1,34 @@ +import { Typography, Box } from '@mui/material'; + +import { components } from 'tg.service/apiSchema.generated'; +import { useBatchOperationStatusTranslate } from 'tg.translationTools/useBatchOperationStatusTranslate'; +import { BatchProgress } from './BatchProgress'; +import { STATIC_STATUSES, useStatusColor } from './utils'; + +type BatchJobModel = components['schemas']['BatchJobModel']; + +type Props = { + data: BatchJobModel; + width?: string; +}; + +export const BatchIndicator = ({ data, width = '100px' }: Props) => { + const statusColor = useStatusColor()(data.status); + const statusLabel = useBatchOperationStatusTranslate()(data.status); + + const isStatic = STATIC_STATUSES.includes(data.status); + + if (isStatic) { + return ( + + {statusLabel} + + ); + } + + return ( + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx new file mode 100644 index 0000000000..b4bbb2cdb8 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx @@ -0,0 +1,113 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; +import { T } from '@tolgee/react'; +import { useEffect } from 'react'; + +import { useProject } from 'tg.hooks/useProject'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { useProjectContext } from 'tg.hooks/ProjectContext'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { useBatchOperationStatusTranslate } from 'tg.translationTools/useBatchOperationStatusTranslate'; + +import { BatchJobModel } from '../types'; +import { BatchProgress } from './BatchProgress'; +import { END_STATUSES, useStatusColor } from './utils'; +import { useBatchOperationTypeTranslate } from 'tg.translationTools/useBatchOperationTypeTranslation'; + +type Props = { + operation: BatchJobModel; + onClose: () => void; + onFinished: () => void; +}; + +export const BatchOperationDialog = ({ + operation, + onClose, + onFinished, +}: Props) => { + const project = useProject(); + + const liveBatch = useProjectContext((c) => + c.batchOperations.find((o) => o.id === operation.id) + ); + + const operationLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/batch-jobs/{id}', + method: 'get', + path: { projectId: project.id, id: operation.id }, + }); + + const data = liveBatch || operationLoadable.data || operation; + + const getStatusColor = useStatusColor(); + + const statusColor = getStatusColor(data.status); + const statusLabel = useBatchOperationStatusTranslate()(data.status); + const typeLabel = useBatchOperationTypeTranslate()(data.type); + + const isFinished = END_STATUSES.includes(data.status); + + useEffect(() => { + if (isFinished) { + onFinished(); + } + }, [isFinished]); + + return ( + + {typeLabel} + + + + + + + {(isFinished || data.status === 'PENDING') && ( + + {statusLabel} + + )} + + {data.errorMessage && ( + + + + )} + + + {isFinished ? ( + + ) : ( + + )} + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx new file mode 100644 index 0000000000..7d41ba82f8 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchProgress.tsx @@ -0,0 +1,32 @@ +import { Box, styled } from '@mui/material'; + +const StyledContainer = styled(Box)` + display: grid; + border-radius: 4px; + background: ${({ theme }) => theme.palette.billingProgress.background}; + overflow: hidden; + transition: all 0.5s ease-in-out; + position: relative; + width: 100%; +`; + +const StyledProgress = styled(Box)` + border-radius: 4px; + background: ${({ theme }) => theme.palette.billingProgress.sufficient}; + transition: all 0.5s ease-in-out; + height: 8px; +`; + +type Props = { + max: number; + progress: number; +}; + +export const BatchProgress = ({ max, progress }: Props) => { + const percent = (progress / (max || 1)) * 100; + return ( + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx new file mode 100644 index 0000000000..7804dc4cfc --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Box, styled, useTheme } from '@mui/material'; + +import { components } from 'tg.service/apiSchema.generated'; +import { BatchIndicator } from './BatchIndicator'; +import { useTolgee, useTranslate } from '@tolgee/react'; +import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { useBatchOperationTypeTranslate } from 'tg.translationTools/useBatchOperationTypeTranslation'; + +type BatchJobModel = components['schemas']['BatchJobModel']; + +const StyledContainer = styled('div')` + display: grid; + grid-template-columns: auto auto auto 1fr auto; + align-items: center; + padding: 15px; + gap: 0 10px; + min-width: 250px; +`; + +const StyledCell = styled(Box)` + margin: 5px 0px; + display: flex; + align-items: center; + white-space: nowrap; +`; + +type Props = { + data: BatchJobModel[]; +}; + +export const OperationsList = ({ data }: Props) => { + const tolgee = useTolgee(['language']); + const translateType = useBatchOperationTypeTranslate(); + const theme = useTheme(); + const { t } = useTranslate(); + return ( + + {data?.map((o) => ( + + + {Intl.DateTimeFormat(tolgee.getLanguage(), { + timeStyle: 'short', + dateStyle: 'short', + }).format(o.updatedAt)} + + {translateType(o.type)} + + {t('batch_operation_progress', { + totalItems: o.totalItems, + progress: o.progress, + })} + + + + + + {o.author && ( + + )} + + {o.errorMessage && ( + + + + )} + + ))} + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsSummary.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsSummary.tsx new file mode 100644 index 0000000000..119b20c8d7 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsSummary.tsx @@ -0,0 +1,46 @@ +import { ExpandMore, PlayCircleOutline } from '@mui/icons-material'; +import { Box, Popper, styled, Tooltip } from '@mui/material'; + +import { useProjectContext } from 'tg.hooks/ProjectContext'; +import { BatchIndicator } from './BatchIndicator'; +import { OperationsList } from './OperationsList'; + +const StyledPopper = styled(Popper)` + .MuiTooltip-tooltip { + max-width: none; + } +`; + +export const BatchOperationsSummary = () => { + const batchOperations = useProjectContext((c) => c.batchOperations); + const running = batchOperations?.find((o) => o.status === 'RUNNING'); + const pending = batchOperations?.find((o) => o.status === 'PENDING'); + const failed = batchOperations?.find((o) => o.status === 'FAILED'); + const cancelled = batchOperations?.find((o) => o.status === 'CANCELLED'); + const success = batchOperations?.find((o) => o.status === 'SUCCESS'); + + const relevantTask = running || pending || failed || cancelled || success; + + if (!batchOperations || !relevantTask) { + return null; + } + + return ( + } + PopperComponent={StyledPopper} + > + + + + + + + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts new file mode 100644 index 0000000000..88f59399b7 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts @@ -0,0 +1,31 @@ +import { useTheme } from '@mui/material'; + +import { BatchJobStatus } from '../types'; + +export function useStatusColor() { + const { palette } = useTheme(); + + return (status: BatchJobStatus) => { + switch (status) { + case 'FAILED': + return palette.error.main; + case 'SUCCESS': + return palette.success.main; + default: + return palette.text.secondary; + } + }; +} + +export const END_STATUSES: BatchJobStatus[] = [ + 'SUCCESS', + 'FAILED', + 'CANCELLED', +]; + +export const STATIC_STATUSES: BatchJobStatus[] = [ + 'FAILED', + 'SUCCESS', + 'CANCELLED', + 'PENDING', +]; diff --git a/webapp/src/views/projects/translations/BatchOperations/types.ts b/webapp/src/views/projects/translations/BatchOperations/types.ts new file mode 100644 index 0000000000..3b72442064 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/types.ts @@ -0,0 +1,21 @@ +import { components } from 'tg.service/apiSchema.generated'; + +export type BatchActions = + | 'delete' + | 'auto_translate' + | 'mark_as_reviewed' + | 'mark_as_translated' + | 'add_tags' + | 'remove_tags' + | 'change_namespace' + | 'copy_translations' + | 'clear_translations'; + +export type BatchJobModel = components['schemas']['BatchJobModel']; + +export interface OperationProps { + disabled: boolean; + onStart: (operation: BatchJobModel) => void; +} + +export type BatchJobStatus = components['schemas']['BatchJobModel']['status']; diff --git a/webapp/src/views/projects/translations/Tags/Tag.tsx b/webapp/src/views/projects/translations/Tags/Tag.tsx index f0c8e7b4cb..f97ece270f 100644 --- a/webapp/src/views/projects/translations/Tags/Tag.tsx +++ b/webapp/src/views/projects/translations/Tags/Tag.tsx @@ -4,13 +4,6 @@ import { Close } from '@mui/icons-material'; import { Wrapper } from './Wrapper'; import clsx from 'clsx'; -type Props = { - name: string; - onDelete?: React.MouseEventHandler; - onClick?: (name: string) => void; - selected?: boolean; -}; - const StyledTag = styled('div')` margin-left: 6px; margin-right: 6px; @@ -37,11 +30,25 @@ const StyledWrapper = styled(Wrapper)` } `; -export const Tag: React.FC = ({ name, onDelete, onClick, selected }) => { +type Props = { + name: string; + onDelete?: React.MouseEventHandler; + onClick?: (name: string) => void; + selected?: boolean; + className?: string; +}; + +export const Tag: React.FC = ({ + name, + onDelete, + onClick, + selected, + className, +}) => { return ( onClick?.(name) : undefined} - className={clsx({ selected })} + className={clsx({ selected }, className)} > {name} {onDelete && ( diff --git a/webapp/src/views/projects/translations/Tags/TagInput.tsx b/webapp/src/views/projects/translations/Tags/TagInput.tsx index 1ea2781b37..b2e2bf389d 100644 --- a/webapp/src/views/projects/translations/Tags/TagInput.tsx +++ b/webapp/src/views/projects/translations/Tags/TagInput.tsx @@ -42,7 +42,9 @@ type Props = { className?: string; autoFocus?: boolean; existing?: string[]; + filtered?: string[]; placeholder?: string; + noNew?: boolean; }; export const TagInput: React.FC = ({ @@ -51,7 +53,9 @@ export const TagInput: React.FC = ({ className, autoFocus, existing, + filtered, placeholder, + noNew, }) => { const [value, setValue] = useState(''); const [search] = useDebounce(value, 500); @@ -104,17 +108,23 @@ export const TagInput: React.FC = ({ PopperComponent={CustomPopper} options={options} filterOptions={(options) => { - const filtered = options.filter((o) => - o.value.toLowerCase().startsWith(search.toLowerCase()) + const result = options.filter( + (o) => + !filtered?.includes(o.value) && + o.value.toLowerCase().startsWith(search.toLowerCase()) ); - if (search !== '' && !options.find((item) => item.value === search)) { - filtered.push({ + if ( + !noNew && + search !== '' && + !options.find((item) => item.value === search) + ) { + result.push({ value: search, label: '', new: true, }); } - return filtered; + return result; }} inputValue={value} onInputChange={(_, value) => { diff --git a/webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts b/webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts index 43b48cc76a..1190e9da86 100644 --- a/webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts +++ b/webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef } from 'react'; + import { stringHash } from 'tg.fixtures/stringHash'; import { useGlobalActions } from 'tg.globalContext/GlobalContext'; -import { useProjectContext } from 'tg.hooks/useProject'; +import { useProjectActions, useProjectContext } from 'tg.hooks/ProjectContext'; import { useApiQuery } from 'tg.service/http/useQueryApi'; import { useTranslationsSelector } from '../context/TranslationsContext'; @@ -28,7 +29,8 @@ export const useTranslationTools = ({ (c) => c.translations?.find((item) => item.keyId === keyId)?.contextPresent ); - const { enabledMtServices, refetchSettings } = useProjectContext(); + const enabledMtServices = useProjectContext((c) => c.enabledMtServices); + const { refetchSettings } = useProjectActions(); const mtServices = useMemo(() => { const settingItem = diff --git a/webapp/src/views/projects/translations/Translations.tsx b/webapp/src/views/projects/translations/Translations.tsx index 6970a0835e..1e9ca0a44c 100644 --- a/webapp/src/views/projects/translations/Translations.tsx +++ b/webapp/src/views/projects/translations/Translations.tsx @@ -18,6 +18,9 @@ import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; import { useGlobalLoading } from 'tg.component/GlobalLoading'; import { BaseProjectView } from '../BaseProjectView'; +import { TranslationsToolbar } from './TranslationsToolbar'; +import { useColumnsContext } from './context/ColumnsContext'; +import { BatchOperationsChangeIndicator } from './BatchOperations/BatchOperationsChangeIndicator'; export const Translations = () => { const { t } = useTranslate(); @@ -28,6 +31,7 @@ export const Translations = () => { const isFetching = useTranslationsSelector((c) => c.isFetching); const view = useTranslationsSelector((v) => v.view); const translations = useTranslationsSelector((c) => c.translations); + const totalWidth = useColumnsContext((c) => c.totalWidth); const filtersOrSearchApplied = useTranslationsSelector((c) => Boolean(Object.values(c.filters).filter(Boolean).length || c.urlSearch) @@ -119,6 +123,7 @@ export const Translations = () => { ], ]} > + {translationsEmpty ? ( renderPlaceholder() @@ -127,6 +132,7 @@ export const Translations = () => { ) : ( )} + ); }; diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx index 609c420027..de2f508fcf 100644 --- a/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx @@ -9,7 +9,6 @@ import { } from '../context/TranslationsContext'; import { ColumnResizer } from '../ColumnResizer'; import { RowList } from './RowList'; -import { TranslationsToolbar } from '../TranslationsToolbar'; import { NamespaceBanner } from '../Namespace/NamespaceBanner'; import { useNsBanners } from '../context/useNsBanners'; import { @@ -47,7 +46,6 @@ export const TranslationsList = () => { const columnSizes = useColumnsContext((c) => c.columnSizes); const columnSizesPercent = useColumnsContext((c) => c.columnSizesPercent); - const totalWidth = useColumnsContext((c) => c.totalWidth); const { startResize, resizeColumn, addResizer, resetColumns } = useColumnsActions(); @@ -154,7 +152,6 @@ export const TranslationsList = () => { ); }} /> - ); }; diff --git a/webapp/src/views/projects/translations/TranslationsSelection.tsx b/webapp/src/views/projects/translations/TranslationsSelection.tsx deleted file mode 100644 index e73911c03d..0000000000 --- a/webapp/src/views/projects/translations/TranslationsSelection.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Delete } from '@mui/icons-material'; -import { styled, IconButton, Tooltip, Checkbox } from '@mui/material'; -import { T, useTranslate } from '@tolgee/react'; -import { useGlobalLoading } from 'tg.component/GlobalLoading'; - -import { - useTranslationsActions, - useTranslationsSelector, -} from './context/TranslationsContext'; - -const StyledContainer = styled('div')` - position: absolute; - display: flex; - bottom: 0px; - right: 0px; - width: 100%; - transition: all 300ms ease-in-out; - flex-shrink: 1; -`; - -const StyledContent = styled('div')` - display: flex; - gap: 10px; - align-items: center; - box-sizing: border-box; - position: relative; - transition: background-color 300ms ease-in-out, visibility 0ms; - padding: ${({ theme }) => theme.spacing(0.5, 2, 0.5, 2)}; - pointer-events: all; - border-radius: 6px; - max-width: 100%; - background-color: ${({ theme }) => theme.palette.emphasis[200]}; - -webkit-box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); - box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); -`; - -const StyledToggleAllButton = styled(IconButton)` - display: flex; - flex-shrink: 1; - width: 38px; - height: 38px; - margin-left: 3px; -`; - -export const TranslationsSelection = () => { - const { t } = useTranslate(); - const selection = useTranslationsSelector((c) => c.selection); - const totalCount = useTranslationsSelector((c) => c.translationsTotal || 0); - const isLoading = useTranslationsSelector((c) => c.isLoadingAllIds); - const isDeleting = useTranslationsSelector((c) => c.isDeleting); - const { selectAll, selectionClear, deleteTranslations } = - useTranslationsActions(); - - const allSelected = totalCount === selection.length; - const somethingSelected = !allSelected && Boolean(selection.length); - - const handleToggleSelectAll = () => { - if (!allSelected) { - selectAll(); - } else { - selectionClear(); - } - }; - - useGlobalLoading(isLoading || isDeleting); - - return ( - - - - - - - - - - - - - - - - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx index d51ea10b0b..fc3464538a 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx @@ -10,7 +10,6 @@ import { import { ColumnResizer } from '../ColumnResizer'; import { CellLanguage } from './CellLanguage'; import { RowTable } from './RowTable'; -import { TranslationsToolbar } from '../TranslationsToolbar'; import { NamespaceBanner } from '../Namespace/NamespaceBanner'; import { useNsBanners } from '../context/useNsBanners'; import { @@ -65,7 +64,6 @@ export const TranslationsTable = () => { const columnSizes = useColumnsContext((c) => c.columnSizes); const columnSizesPercent = useColumnsContext((c) => c.columnSizesPercent); - const totalWidth = useColumnsContext((c) => c.totalWidth); const { startResize, resizeColumn, addResizer, resetColumns } = useColumnsActions(); @@ -204,7 +202,6 @@ export const TranslationsTable = () => { ); }} /> - ); }; diff --git a/webapp/src/views/projects/translations/TranslationsToolbar.tsx b/webapp/src/views/projects/translations/TranslationsToolbar.tsx index 15e922e07d..666599ae5c 100644 --- a/webapp/src/views/projects/translations/TranslationsToolbar.tsx +++ b/webapp/src/views/projects/translations/TranslationsToolbar.tsx @@ -7,7 +7,7 @@ import { useDebouncedCallback } from 'use-debounce'; import { useTranslationsSelector } from './context/TranslationsContext'; import { TranslationsShortcuts } from './TranslationsShortcuts'; -import { TranslationsSelection } from './TranslationsSelection'; +import { BatchOperations } from './BatchOperations/BatchOperations'; const StyledContainer = styled('div')` z-index: ${({ theme }) => theme.zIndex.drawer}; @@ -16,7 +16,7 @@ const StyledContainer = styled('div')` align-items: stretch; justify-content: space-between; bottom: 0px; - right: 0px; + left: 44px; pointer-events: none; `; @@ -139,7 +139,11 @@ export const TranslationsToolbar: React.FC = ({ width }) => { onPointerLeave={handlePointerLeave} > - {selectionOpen ? : } + setIsMouseOver(false)} + /> + {!selectionOpen && } ; export const [ColumnsContext, useColumnsActions, useColumnsContext] = - createProviderNew(() => { + createProvider(() => { const [columnSizes, setColumnSizes] = useState(); const [tableRef, setTableRef] = useState({ diff --git a/webapp/src/views/projects/translations/context/HeaderNsContext.ts b/webapp/src/views/projects/translations/context/HeaderNsContext.ts index 721a255055..23efba3280 100644 --- a/webapp/src/views/projects/translations/context/HeaderNsContext.ts +++ b/webapp/src/views/projects/translations/context/HeaderNsContext.ts @@ -3,14 +3,14 @@ import { useState } from 'react'; import { useTranslationsSelector } from './TranslationsContext'; import { useDebouncedCallback } from 'use-debounce'; import { NsBannerRecord, useNsBanners } from './useNsBanners'; -import { createProviderNew } from 'tg.fixtures/createProviderNew'; +import { createProvider } from 'tg.fixtures/createProvider'; /** * Context responsible for top namespace banner in translations header * keeps track of banner elements and decides which one should be displayed */ export const [HeaderNsContext, useHeaderNsActions, useHeaderNsContext] = - createProviderNew(() => { + createProvider(() => { const translations = useTranslationsSelector((c) => c.translations); const reactList = useTranslationsSelector((c) => c.reactList); const [topNamespace, setTopNamespace] = useState< diff --git a/webapp/src/views/projects/translations/context/TranslationsContext.ts b/webapp/src/views/projects/translations/context/TranslationsContext.ts index 063f5a65ae..810772f0eb 100644 --- a/webapp/src/views/projects/translations/context/TranslationsContext.ts +++ b/webapp/src/views/projects/translations/context/TranslationsContext.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import ReactList from 'react-list'; import { useApiQuery } from 'tg.service/http/useQueryApi'; -import { createProviderNew } from 'tg.fixtures/createProviderNew'; +import { createProvider } from 'tg.fixtures/createProvider'; import { container } from 'tsyringe'; import { ProjectPreferencesService } from 'tg.service/ProjectPreferencesService'; import { useTranslationsService } from './services/useTranslationsService'; @@ -48,7 +48,7 @@ export const [ TranslationsContextProvider, useTranslationsActions, useTranslationsSelector, -] = createProviderNew((props: Props) => { +] = createProvider((props: Props) => { const [view, setView] = useUrlSearchState('view', { defaultVal: 'LIST' }); const urlLanguages = useUrlSearchArray().languages; const requiredLanguages = urlLanguages?.length diff --git a/webapp/src/views/projects/translations/context/services/useWebsocketListener.ts b/webapp/src/views/projects/translations/context/services/useWebsocketListener.ts index 14a7e5bf35..bf84594bdb 100644 --- a/webapp/src/views/projects/translations/context/services/useWebsocketListener.ts +++ b/webapp/src/views/projects/translations/context/services/useWebsocketListener.ts @@ -7,7 +7,7 @@ import { useEffect } from 'react'; import { Modification, WebsocketClient, -} from '../../../../../websocket-client/WebsocketClient'; +} from 'tg.websocket-client/WebsocketClient'; export const useWebsocketListener = ( translationService: ReturnType diff --git a/webapp/src/websocket-client/WebsocketClient.ts b/webapp/src/websocket-client/WebsocketClient.ts index 6838f4d849..abcbf7b82d 100644 --- a/webapp/src/websocket-client/WebsocketClient.ts +++ b/webapp/src/websocket-client/WebsocketClient.ts @@ -2,6 +2,8 @@ import { CompatClient, Stomp } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; import { components } from 'tg.service/apiSchema.generated'; +type BatchJobModelStatus = components['schemas']['BatchJobModel']['status']; + type TranslationsClientOptions = { serverUrl?: string; authentication: { @@ -131,7 +133,7 @@ export const WebsocketClient = (options: TranslationsClientOptions) => { return Object.freeze({ subscribe, disconnect }); }; -export type EventType = 'translation-data-modified'; +export type EventType = 'translation-data-modified' | 'batch-job-progress'; export type Channel = `/projects/${number}/${EventType}`; export type TranslationsModifiedData = WebsocketEvent<{ @@ -139,6 +141,13 @@ export type TranslationsModifiedData = WebsocketEvent<{ keys: EntityModification<'key'>[] | null; }>; +export type BatchJobProgress = WebsocketEvent<{ + jobId: number; + processed: number; + status: BatchJobModelStatus; + total: number; +}>; + export type EntityModification = T extends keyof schemas ? { id: number; @@ -200,4 +209,6 @@ export type Modification = { old: T; new: T }; export type Data = T extends `/projects/${number}/translation-data-modified` ? TranslationsModifiedData + : T extends `/projects/${number}/batch-job-progress` + ? BatchJobProgress : never; diff --git a/webapp/tsconfig.extend.json b/webapp/tsconfig.extend.json index 36ddb9bdbc..b75ac2dbea 100644 --- a/webapp/tsconfig.extend.json +++ b/webapp/tsconfig.extend.json @@ -13,7 +13,8 @@ "tg.error/*": ["error/*"], "tg.svgs/*": ["svgs/*"], "tg.translationTools/*": ["translationTools/*"], - "tg.ee/*": ["ee/*"] + "tg.ee/*": ["ee/*"], + "tg.websocket-client/*": ["websocket-client/*"] } } } From d89579968227db801c48aee5a0479ebd45169269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Tue, 25 Jul 2023 12:27:41 +0200 Subject: [PATCH 02/17] feat: add cancel operation button --- webapp/src/hooks/ProjectContext.tsx | 6 +- webapp/src/service/apiSchema.generated.ts | 44 +- .../src/service/billingApiSchema.generated.ts | 634 ++++++++++++++++++ .../BatchOperationsChangeIndicator.tsx | 26 +- .../BatchOperationDialog.tsx | 2 +- .../OperationAbortButton.tsx | 59 ++ .../OperationsSummary/OperationsList.tsx | 11 +- .../OperationsSummary/OperationsSummary.tsx | 10 +- .../OperationsSummary/utils.ts | 2 + .../services/useTranslationsService.tsx | 16 +- 10 files changed, 759 insertions(+), 51 deletions(-) create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationAbortButton.tsx diff --git a/webapp/src/hooks/ProjectContext.tsx b/webapp/src/hooks/ProjectContext.tsx index 04df215458..456f0170fb 100644 --- a/webapp/src/hooks/ProjectContext.tsx +++ b/webapp/src/hooks/ProjectContext.tsx @@ -160,9 +160,9 @@ export const [ProjectContext, useProjectActions, useProjectContext] = const contextData = { project: project.data, enabledMtServices: settings.data?._embedded?.languageConfigs, - batchOperations: batchOperations?.filter( - (o) => o.type - ) as BatchJobModel[], + batchOperations: batchOperations?.filter((o) => o.type) as + | BatchJobModel[] + | undefined, }; const actions = { diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 75354fd031..525d92903e 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -611,6 +611,14 @@ 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 @@ -654,14 +662,6 @@ export interface components { | "batch-jobs.cancel" | "batch-auto-translate" )[]; - /** - * @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 */ @@ -1333,17 +1333,17 @@ export interface components { key: string; /** Format: int64 */ id: number; + userFullName?: string; + projectName: string; username?: string; description: string; /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ projectId: number; /** Format: int64 */ + lastUsedAt?: number; + /** Format: int64 */ expiresAt?: number; scopes: string[]; - projectName: string; - userFullName?: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -1958,6 +1958,7 @@ export interface components { name: string; /** Format: int64 */ id: number; + basePermissions: components["schemas"]["PermissionModel"]; /** @example This is a beautiful organization full of beautiful and clever people */ description?: string; /** @@ -1966,10 +1967,9 @@ export interface components { * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; - avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; - basePermissions: components["schemas"]["PermissionModel"]; + avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -2001,8 +2001,8 @@ export interface components { postHogHost?: string; }; DocItem: { - displayName?: string; name: string; + displayName?: string; description?: string; }; PagedModelProjectModel: { @@ -2070,18 +2070,18 @@ export interface components { name: string; /** Format: int64 */ id: number; + baseTranslation?: string; namespace?: string; translation?: string; - baseTranslation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; name: string; /** Format: int64 */ id: number; + baseTranslation?: string; namespace?: string; translation?: string; - baseTranslation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -2211,7 +2211,6 @@ export interface components { page?: components["schemas"]["PageMetadata"]; }; EntityModelImportFileIssueView: { - params: components["schemas"]["ImportFileIssueParamView"][]; /** Format: int64 */ id: number; type: @@ -2224,6 +2223,7 @@ export interface components { | "ID_ATTRIBUTE_NOT_PROVIDED" | "TARGET_NOT_PROVIDED" | "TRANSLATION_TOO_LONG"; + params: components["schemas"]["ImportFileIssueParamView"][]; }; ImportFileIssueParamView: { value?: string; @@ -2646,17 +2646,17 @@ export interface components { permittedLanguageIds?: number[]; /** Format: int64 */ id: number; + userFullName?: string; + projectName: string; username?: string; description: string; /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ projectId: number; /** Format: int64 */ + lastUsedAt?: number; + /** Format: int64 */ expiresAt?: number; scopes: string[]; - projectName: string; - userFullName?: string; }; PagedModelUserAccountModel: { _embedded?: { diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts index 15fe3ecdf4..8ce535b577 100644 --- a/webapp/src/service/billingApiSchema.generated.ts +++ b/webapp/src/service/billingApiSchema.generated.ts @@ -19,6 +19,16 @@ export interface paths { "/v2/organizations/{organizationId}/billing/cancel-subscription": { put: operations["cancelSubscription"]; }; + "/v2/administration/billing/self-hosted-ee-plans/{planId}": { + get: operations["getPlan"]; + put: operations["updatePlan"]; + delete: operations["deletePlan"]; + }; + "/v2/administration/billing/cloud-plans/{planId}": { + get: operations["getPlan_1"]; + put: operations["updatePlan_1"]; + delete: operations["deletePlan_1"]; + }; "/v2/organizations/{organizationId}/billing/subscribe": { post: operations["subscribe"]; }; @@ -29,6 +39,14 @@ export interface paths { "/v2/organizations/{organizationId}/billing/buy-more-credits": { post: operations["getBuyMoreCreditsCheckoutSessionUrl"]; }; + "/v2/administration/billing/self-hosted-ee-plans": { + get: operations["getPlans_1"]; + post: operations["create"]; + }; + "/v2/administration/billing/cloud-plans": { + get: operations["getPlans_2"]; + post: operations["create_1"]; + }; "/v2/public/billing/plans": { get: operations["getPlans"]; }; @@ -70,6 +88,18 @@ export interface paths { "/v2/organizations/{organizationId}/billing/billing-info": { get: operations["getBillingInfo"]; }; + "/v2/administration/billing/stripe-products": { + get: operations["getStripeProducts"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/{planId}/organizations": { + get: operations["getPlanOrganizations"]; + }; + "/v2/administration/billing/features": { + get: operations["getAllFeatures"]; + }; + "/v2/administration/billing/cloud-plans/{planId}/organizations": { + get: operations["getPlanOrganizations_1"]; + }; "/v2/organizations/{organizationId}/billing/self-hosted-ee/subscriptions/{subscriptionId}": { delete: operations["cancelEeSubscription"]; }; @@ -166,6 +196,7 @@ export interface components { prices: components["schemas"]["PlanPricesModel"]; includedUsage: components["schemas"]["PlanIncludedUsageModel"]; hasYearlyPrice: boolean; + public: boolean; }; CloudSubscriptionModel: { /** Format: int64 */ @@ -203,6 +234,124 @@ export interface components { prorationDate: number; endingBalance: number; }; + PlanIncludedUsageRequest: { + /** Format: int64 */ + seats: number; + /** Format: int64 */ + translations: number; + /** Format: int64 */ + mtCredits: number; + }; + PlanPricesRequest: { + perSeat: number; + perThousandTranslations?: number; + perThousandMtCredits?: number; + subscriptionMonthly: number; + subscriptionYearly: number; + }; + SelfHostedEePlanRequest: { + name: string; + free: boolean; + enabledFeatures: ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + )[]; + prices: components["schemas"]["PlanPricesRequest"]; + includedUsage: components["schemas"]["PlanIncludedUsageRequest"]; + public: boolean; + stripeProductId: string; + /** Format: date-time */ + notAvailableBefore?: string; + /** Format: date-time */ + availableUntil?: string; + /** Format: date-time */ + usableUntil?: string; + forOrganizationIds: number[]; + }; + SelfHostedEePlanAdministrationModel: { + /** 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" + )[]; + prices: components["schemas"]["PlanPricesModel"]; + includedUsage: components["schemas"]["PlanIncludedUsageModel"]; + hasYearlyPrice: boolean; + stripeProductId: string; + forOrganizationIds: number[]; + }; + CloudPlanRequest: { + name: string; + free: boolean; + enabledFeatures: ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + )[]; + type: "PAY_AS_YOU_GO" | "FIXED" | "SLOTS_FIXED"; + prices: components["schemas"]["PlanPricesRequest"]; + includedUsage: components["schemas"]["PlanIncludedUsageRequest"]; + public: boolean; + stripeProductId: string; + /** Format: date-time */ + notAvailableBefore?: string; + /** Format: date-time */ + availableUntil?: string; + /** Format: date-time */ + usableUntil?: string; + forOrganizationIds: number[]; + }; + CloudPlanAdministrationModel: { + /** Format: int64 */ + id: number; + name: string; + free: boolean; + enabledFeatures: ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + )[]; + type: "PAY_AS_YOU_GO" | "FIXED" | "SLOTS_FIXED"; + prices: components["schemas"]["PlanPricesModel"]; + includedUsage: components["schemas"]["PlanIncludedUsageModel"]; + hasYearlyPrice: boolean; + public: boolean; + stripeProductId: string; + forOrganizationIds: number[]; + }; CloudSubscribeRequest: { /** * Format: int64 @@ -322,6 +471,102 @@ export interface components { vatNo?: string; email?: string; }; + CollectionModelStripeProductModel: { + _embedded?: { + stripeProductModels?: components["schemas"]["StripeProductModel"][]; + }; + }; + StripeProductModel: { + id: string; + name: string; + /** Format: int64 */ + created: number; + }; + CollectionModelSelfHostedEePlanAdministrationModel: { + _embedded?: { + plans?: components["schemas"]["SelfHostedEePlanAdministrationModel"][]; + }; + }; + /** @example Links to avatar images */ + Avatar: { + large: string; + thumbnail: string; + }; + PagedModelSimpleOrganizationModel: { + _embedded?: { + organizations?: components["schemas"]["SimpleOrganizationModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + PermissionModel: { + /** + * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. + * @example KEYS_EDIT,TRANSLATIONS_VIEW + */ + scopes: ( + | "translations.view" + | "translations.edit" + | "keys.edit" + | "screenshots.upload" + | "screenshots.delete" + | "screenshots.view" + | "activity.view" + | "languages.edit" + | "admin" + | "project.edit" + | "members.view" + | "members.edit" + | "translation-comments.add" + | "translation-comments.edit" + | "translation-comments.set-state" + | "translations.state-edit" + | "keys.view" + | "keys.delete" + | "keys.create" + )[]; + /** @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 + */ + 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[]; + }; + SimpleOrganizationModel: { + /** Format: int64 */ + id: number; + /** @example Beautiful organization */ + name: string; + /** @example btforg */ + slug: string; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; + basePermissions: components["schemas"]["PermissionModel"]; + avatar?: components["schemas"]["Avatar"]; + }; + CollectionModelCloudPlanAdministrationModel: { + _embedded?: { + plans?: components["schemas"]["CloudPlanAdministrationModel"][]; + }; + }; Link: { href?: string; hreflang?: string; @@ -473,6 +718,170 @@ export interface operations { }; }; }; + getPlan: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["SelfHostedEePlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + updatePlan: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["SelfHostedEePlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SelfHostedEePlanRequest"]; + }; + }; + }; + deletePlan: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + getPlan_1: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["CloudPlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + updatePlan_1: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["CloudPlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloudPlanRequest"]; + }; + }; + }; + deletePlan_1: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; subscribe: { parameters: { path: { @@ -596,6 +1005,104 @@ export interface operations { }; }; }; + getPlans_1: { + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["CollectionModelSelfHostedEePlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + create: { + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["SelfHostedEePlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SelfHostedEePlanRequest"]; + }; + }; + }; + getPlans_2: { + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["CollectionModelCloudPlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + create_1: { + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["CloudPlanAdministrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloudPlanRequest"]; + }; + }; + }; getPlans: { responses: { /** OK */ @@ -952,6 +1459,133 @@ export interface operations { }; }; }; + getStripeProducts: { + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["CollectionModelStripeProductModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + getPlanOrganizations: { + parameters: { + path: { + planId: number; + }; + 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; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["PagedModelSimpleOrganizationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + getAllFeatures: { + responses: { + /** OK */ + 200: { + content: { + "*/*": ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + )[]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + getPlanOrganizations_1: { + parameters: { + path: { + planId: number; + }; + 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; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["PagedModelSimpleOrganizationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; cancelEeSubscription: { parameters: { path: { diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx index 43b44a6569..95b6c14e91 100644 --- a/webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/BatchOperationsChangeIndicator.tsx @@ -1,6 +1,7 @@ -import { Alert, Box, Button, Portal } from '@mui/material'; +import { Alert, Box, Portal } from '@mui/material'; import { useTranslate } from '@tolgee/react'; import { useEffect, useState } from 'react'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; import { useProjectContext } from 'tg.hooks/ProjectContext'; import { useTranslationsActions, @@ -11,19 +12,21 @@ import { END_STATUSES } from './OperationsSummary/utils'; export const BatchOperationsChangeIndicator = () => { const { t } = useTranslate(); const { refetchTranslations } = useTranslationsActions(); + const [isRefetching, setIsRefetching] = useState(false); const lastJob = useProjectContext((c) => { return c.batchOperations ? c.batchOperations.find((o) => END_STATUSES.includes(o.status))?.id : 'not-loaded'; }); - const translations = useTranslationsSelector((c) => c.translations); - const translationsFetching = useTranslationsSelector((c) => c.isFetching); + const isFetching = useTranslationsSelector((c) => c.isFetching); const [previousLast, setPreviousLast] = useState(lastJob); const [dataChanged, setDataChanged] = useState(false); - function handleRefetch() { - refetchTranslations(); + async function handleRefetch() { + setIsRefetching(true); + await refetchTranslations(); + setIsRefetching(false); } // check when job is finished @@ -41,20 +44,25 @@ export const BatchOperationsChangeIndicator = () => { // reset outdated status when data are updated useEffect(() => { setDataChanged(false); - }, [translations, translationsFetching]); + }, [isFetching]); return ( <> - {dataChanged && ( + {(dataChanged || isRefetching) && ( + {t('batch_operations_refresh_button')} - + } > {t('batch_operations_outdated_message')} diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx index b4bbb2cdb8..44947b4e02 100644 --- a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx @@ -34,7 +34,7 @@ export const BatchOperationDialog = ({ const project = useProject(); const liveBatch = useProjectContext((c) => - c.batchOperations.find((o) => o.id === operation.id) + c.batchOperations?.find((o) => o.id === operation.id) ); const operationLoadable = useApiQuery({ diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationAbortButton.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationAbortButton.tsx new file mode 100644 index 0000000000..4dc00f3b06 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationAbortButton.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import clsx from 'clsx'; +import { Box, CircularProgress, styled } from '@mui/material'; +import { Close } from '@mui/icons-material'; + +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; + +type BatchJobModel = components['schemas']['BatchJobModel']; + +const AbortButton = styled(Box)` + cursor: pointer; + display: flex; + align-items: center; + margin: 0px -5px; + width: 20px; + height: 20px; + + &.disabled { + pointer-events: none; + color: ${({ theme }) => theme.palette.emphasis[800]}; + } +`; + +type Props = { + operation: BatchJobModel; +}; + +export function OperationAbortButton({ operation }: Props) { + const project = useProject(); + const [cancelled, setCancelled] = useState(false); + + const cancelLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/batch-jobs/{id}/cancel', + method: 'put', + }); + + function handleCancel() { + setCancelled(true); + cancelLoadable.mutate({ + path: { projectId: project.id, id: operation.id }, + }); + } + + return ( + + {!cancelled ? ( + + ) : ( + + )} + + ); +} diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx index 7804dc4cfc..69aaf43bad 100644 --- a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/OperationsList.tsx @@ -7,12 +7,14 @@ import { useTolgee, useTranslate } from '@tolgee/react'; import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; import { TranslatedError } from 'tg.translationTools/TranslatedError'; import { useBatchOperationTypeTranslate } from 'tg.translationTools/useBatchOperationTypeTranslation'; +import { OperationAbortButton } from './OperationAbortButton'; +import { CANCELLABLE_STATUSES } from './utils'; type BatchJobModel = components['schemas']['BatchJobModel']; const StyledContainer = styled('div')` display: grid; - grid-template-columns: auto auto auto 1fr auto; + grid-template-columns: auto auto auto 1fr auto auto; align-items: center; padding: 15px; gap: 0 10px; @@ -35,6 +37,7 @@ export const OperationsList = ({ data }: Props) => { const translateType = useBatchOperationTypeTranslate(); const theme = useTheme(); const { t } = useTranslate(); + return ( {data?.map((o) => ( @@ -42,7 +45,6 @@ export const OperationsList = ({ data }: Props) => { {Intl.DateTimeFormat(tolgee.getLanguage(), { timeStyle: 'short', - dateStyle: 'short', }).format(o.updatedAt)} {translateType(o.type)} @@ -68,6 +70,11 @@ export const OperationsList = ({ data }: Props) => { /> )} + + {CANCELLABLE_STATUSES.includes(o.status) && ( + + )} + {o.errorMessage && ( { const batchOperations = useProjectContext((c) => c.batchOperations); - const running = batchOperations?.find((o) => o.status === 'RUNNING'); - const pending = batchOperations?.find((o) => o.status === 'PENDING'); - const failed = batchOperations?.find((o) => o.status === 'FAILED'); - const cancelled = batchOperations?.find((o) => o.status === 'CANCELLED'); - const success = batchOperations?.find((o) => o.status === 'SUCCESS'); - - const relevantTask = running || pending || failed || cancelled || success; + const relevantTask = + batchOperations?.find((o) => o.status === 'RUNNING') || + batchOperations?.[0]; if (!batchOperations || !relevantTask) { return null; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts index 88f59399b7..ba52b91173 100644 --- a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/utils.ts @@ -29,3 +29,5 @@ export const STATIC_STATUSES: BatchJobStatus[] = [ 'CANCELLED', 'PENDING', ]; + +export const CANCELLABLE_STATUSES: BatchJobStatus[] = ['PENDING', 'RUNNING']; diff --git a/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx b/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx index 27daeb9b01..6de6cf7802 100644 --- a/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx +++ b/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx @@ -191,13 +191,15 @@ export const useTranslationsService = (props: Props) => { }; const refetchTranslations = (callback?: () => any) => { - // force refetch from first page - translations.remove(); - callback?.(); - window?.scrollTo(0, 0); - setTimeout(() => { - // make sure that we are refetching, but prevent double fetch - translations.refetch(); + return new Promise((resolve) => { + // force refetch from first page + translations.remove(); + callback?.(); + window?.scrollTo(0, 0); + setTimeout(() => { + // make sure that we are refetching, but prevent double fetch + translations.refetch().then(() => resolve()); + }); }); }; From cf9b2cfda8f9b21f852a644dc218c109faa725ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Gran=C3=A1t?= Date: Tue, 25 Jul 2023 13:07:50 +0200 Subject: [PATCH 03/17] feat: add cancel button to operation dialog --- e2e/cypress/support/dataCyType.d.ts | 1 + .../BatchOperationDialog.tsx | 23 +++++++++- .../OperationAbortButton.tsx | 23 +++------- .../OperationsSummary/OperationsList.tsx | 5 +-- .../OperationsSummary/useOperationCancel.tsx | 45 +++++++++++++++++++ 5 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 webapp/src/views/projects/translations/BatchOperations/OperationsSummary/useOperationCancel.tsx diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 08828db14f..4149d00ae1 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -68,6 +68,7 @@ declare namespace DataCy { "avatar-upload-button" | "avatar-upload-file-input" | "base-language-select" | + "batch-operation-dialog-cancel-job" | "batch-operation-dialog-end-status" | "batch-operation-dialog-minimize" | "batch-operation-dialog-ok" | diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx index 44947b4e02..b0fb368762 100644 --- a/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/OperationsSummary/BatchOperationDialog.tsx @@ -6,7 +6,7 @@ import { DialogContent, DialogTitle, } from '@mui/material'; -import { T } from '@tolgee/react'; +import { T, useTranslate } from '@tolgee/react'; import { useEffect } from 'react'; import { useProject } from 'tg.hooks/useProject'; @@ -19,6 +19,8 @@ import { BatchJobModel } from '../types'; import { BatchProgress } from './BatchProgress'; import { END_STATUSES, useStatusColor } from './utils'; import { useBatchOperationTypeTranslate } from 'tg.translationTools/useBatchOperationTypeTranslation'; +import { useOperationCancel } from './useOperationCancel'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; type Props = { operation: BatchJobModel; @@ -31,6 +33,7 @@ export const BatchOperationDialog = ({ onClose, onFinished, }: Props) => { + const { t } = useTranslate(); const project = useProject(); const liveBatch = useProjectContext((c) => @@ -53,6 +56,10 @@ export const BatchOperationDialog = ({ const isFinished = END_STATUSES.includes(data.status); + const { cancelable, handleCancel, loading } = useOperationCancel({ + operation: data, + }); + useEffect(() => { if (isFinished) { onFinished(); @@ -94,7 +101,19 @@ export const BatchOperationDialog = ({ )} - + + {cancelable ? ( + + {t('batch_operations_dialog_cancel_job')} + + ) : ( +
+ )} + {isFinished ? (