import { useEffect, useMemo, useState } from 'react'; import { UiProps } from '@tolgee/core'; import { TolgeeFormat, getTolgeeFormat, getTolgeePlurals, tolgeeFormatGenerateIcu, } from '@tginternal/editor'; import { getVisibleCharCount } from '../editor/getVisibleCharCount'; import { sleep } from '../../tools/sleep'; import { createProvider } from '../../tools/createProvider'; import { putBaseLangFirst, putBaseLangFirstTags } from '../languageHelpers'; import { useApiMutation, useApiQuery } from '../../client/useQueryApi'; import { changeInTolgeeCache, getInitialLanguages, getPreferredLanguages, mapPosition, setPreferredLanguages, } from './tools'; import { useGallery } from './useGallery'; import { checkPlatformVersion } from '../../tools/checkPlatformVersion'; import { limitSurroundingKeys } from '../../tools/limitSurroundingKeys'; import { StateInType, STATES_FOR_UPDATE, StateType, } from '../State/translationStates'; import { useComputedPermissions } from './usePermissions'; import { HttpError } from '../../client/HttpError'; import { components } from '../../client/apiSchema.generated'; import { isTranslationEmpty } from '../../tools/isTranslationEmpty'; const MINIMAL_PLATFORM_VERSION = 'v3.42.0'; type LanguageModel = components['schemas']['LanguageModel']; type FormTranslations = { [key: string]: { value: TolgeeFormat; state: StateType; }; }; type DialogProps = { keyName: string; defaultValue: string; onClose: () => void; uiProps: UiProps; fallbackNamespaces: string[]; namespace: string; children: React.ReactNode; }; export const [DialogProvider, useDialogActions, useDialogContext] = createProvider((props: DialogProps) => { const [success, setSuccess] = useState(false); const [translationsForm, _setTranslationsForm] = useState( {} ); function setTranslation(language: string, value: TolgeeFormat) { _setTranslationsForm((val) => ({ ...val, [language]: { ...val[language], value, }, })); } function setState(language: string, state: StateType) { _setTranslationsForm((value) => ({ ...value, [language]: { ...value[language], state, }, })); } const [saving, setSaving] = useState(false); const [selectedNs, setSelectedNs] = useState(props.namespace); const [tags, setTags] = useState(undefined); const [_isPlural, setIsPlural] = useState(); const [_pluralArgName, setPluralArgName] = useState(); const [_maxCharLimit, setMaxCharLimit] = useState(); const [submitError, setSubmitError] = useState(); const [readOnly, setReadOnly] = useState(false); const branchParam = props.uiProps.branch; const filterTagMissing = Boolean(props.uiProps.filterTag?.length) && tags && !props.uiProps.filterTag.find((t) => tags.includes(t)); useEffect(() => { // reset when key changes setIsPlural(undefined); setPluralArgName(undefined); setReadOnly(false); }, [props.keyName, props.namespace, props.uiProps.branch]); const { screenshots, setScreenshots, screenshotDetail, setScreenshotDetail, screenshotsUploading, takingScreenshot, handleRemoveScreenshot, handleUploadImages, deleteImages, canTakeScreenshots, error: galleryError, ...galleryProps } = useGallery(props.uiProps); const scopesLoadable = useApiQuery({ url: '/v2/api-keys/current-permissions', method: 'get', query: { projectId: Number(props.uiProps.projectId), }, }); const canModifyProtectedBranch = scopesLoadable.data?.scopes?.includes( 'branch.protected-modify' ); const icuPlaceholders = scopesLoadable.data?.project?.icuPlaceholders; const pluralsSupported = icuPlaceholders !== undefined; const languagesLoadable = useApiQuery({ url: '/v2/projects/languages', method: 'get', query: { size: 1000, }, options: { onSuccess(data) { const selectedLanguages = getInitialLanguages( data._embedded?.languages?.map((l) => l.tag!) || [] ); initializeWithDefaultValue(undefined, data._embedded?.languages); setSelectedLanguages(selectedLanguages); setPreferredLanguages(selectedLanguages); }, }, }); const createKey = useApiMutation({ url: '/v2/projects/keys/create', method: 'post', }); const availableLanguages = useMemo(() => { return putBaseLangFirst(languagesLoadable.data?._embedded?.languages); }, [languagesLoadable.data]); const [selectedLanguages, setSelectedLanguages] = useState( getPreferredLanguages() ); const translationsLoadable = useApiQuery({ url: '/v2/projects/translations', method: 'get', query: { filterKeyName: [props.keyName], filterNamespace: [selectedNs], languages: selectedLanguages, branch: branchParam, }, options: { enabled: Boolean(scopesLoadable.data), keepPreviousData: true, onSuccess(data) { const result: FormTranslations = {}; const keyData = data._embedded?.keys?.[0]; if (keyData) { const isPlural = Boolean(keyData.keyIsPlural); data.selectedLanguages?.forEach((lang) => { const translation = keyData?.translations[lang.tag]; result[lang.tag] = { value: getTolgeeFormat( translation?.text || '', isPlural, !icuPlaceholders ), state: translation?.state || 'UNTRANSLATED', }; }); if (_pluralArgName === undefined && isPlural) { setPluralArgName(keyData?.keyPluralArgName); } } else if (props.defaultValue) { const parsed = getTolgeePlurals( props.defaultValue, !icuPlaceholders ); setIsPlural(Boolean(parsed.parameter)); setPluralArgName(parsed.parameter); } initializeWithDefaultValue(result, undefined); if (keyData) { setTags(keyData?.keyTags?.map((t) => t.name) || []); } else { setTags([ ...(props.uiProps.filterTag ?? []), ...(props.uiProps.tagNewKeys ?? []), ]); } setScreenshots( keyData?.screenshots?.map((sc) => ({ ...sc, filename: sc.filename!, justUploaded: false, })) || [] ); }, }, }); function initializeWithDefaultValue( translationData: FormTranslations | undefined, languagesData: LanguageModel[] | undefined ) { const data = translationData ?? (translationsLoadable.isSuccess ? undefined : translationsForm); const languages = languagesData ?? languagesLoadable.data?._embedded?.languages; if (!data) { return undefined; } if (!languages) { _setTranslationsForm({ ...data, }); return undefined; } const baseLang = languages.find((l) => l.base); if (!baseLang?.tag) { return undefined; } const baseLangIncluded = selectedLanguages.includes(baseLang.tag); const baseValueEmpty = isTranslationEmpty( data?.[baseLang.tag]?.value, isPlural ); if (data && baseLangIncluded && baseValueEmpty && props.defaultValue) { _setTranslationsForm({ ...data, [baseLang.tag]: { state: data?.[baseLang.tag]?.state ?? 'UNTRANSLATED', value: getTolgeePlurals(props.defaultValue, !icuPlaceholders), }, }); } else { _setTranslationsForm({ ...data, }); } } // When branchParam is undefined, fetches default branch info const branchLoadable = useApiQuery({ url: '/v2/projects/branches/find', method: 'get', query: { name: branchParam, }, options: { retry: false, }, }); useEffect(() => { if ( branchLoadable.data?.isProtected && canModifyProtectedBranch === false ) { setReadOnly(true); } }, [branchLoadable.data?.isProtected, canModifyProtectedBranch]); const keyData = translationsLoadable.data?._embedded?.keys?.[0]; const isPlural = _isPlural !== undefined ? _isPlural : Boolean(keyData?.keyIsPlural); const pluralArgName = isPlural ? _pluralArgName || 'value' : undefined; const keyExists = Boolean( translationsLoadable.data?._embedded?.keys?.length ); const permissions = useComputedPermissions( scopesLoadable.data, translationsLoadable?.data?._embedded?.keys?.[0], languagesLoadable.data?._embedded?.languages ); const updateKey = useApiMutation({ url: '/v2/projects/keys/{id}/complex-update', method: 'put', }); const linkToPlatform = scopesLoadable.data?.projectId !== undefined ? `${props.uiProps.apiUrl}/projects/${ scopesLoadable.data.projectId }/translations/single${ branchParam ? `/tree/${encodeURIComponent(branchParam)}` : '' }?key=${encodeURIComponent(props.keyName)}${ selectedNs ? `&ns=${encodeURIComponent(selectedNs)}` : '' }` : undefined; const [container, setContainer] = useState( undefined as Element | undefined ); const [useBrowserWindow, setUseBrowserWindow] = useState(false); function onInputChange(key: string, value: TolgeeFormat) { setSubmitError(undefined); setSuccess(false); setTranslation(key, value); } function onStateChange(key: string, value: StateType) { setSuccess(false); setState(key, value); } async function onSave() { setSaving(true); try { const newTranslations = {} as Record; const newStates = {} as Record; Object.entries(translationsForm).forEach(([language, value]) => { const canBeTranslated = permissions.canEditTranslation(language); const stateCanBeChanged = permissions.canEditState(language); if (canBeTranslated) { newTranslations[language] = tolgeeFormatGenerateIcu( { ...value.value, parameter: pluralArgName }, !icuPlaceholders ); } if ( STATES_FOR_UPDATE.includes(value.state as StateInType) && keyData?.translations?.[language]?.state !== value.state && stateCanBeChanged ) { newStates[language] = value.state as StateInType; } }); const relatedKeysInOrder = permissions.canSendBigMeta ? limitSurroundingKeys(props.uiProps.findPositions(), { keyName: props.keyName, keyNamespace: selectedNs, }) : undefined; await (keyData === undefined ? createKey.mutateAsync({ content: { 'application/json': { name: props.keyName, branch: branchParam, namespace: selectedNs || undefined, translations: newTranslations, states: newStates, screenshots: screenshots.map((sc) => ({ uploadedImageId: sc.id, positions: sc.keyReferences?.map(mapPosition), })), tags, relatedKeysInOrder, isPlural, pluralArgName, maxCharLimit: maxCharLimit ?? null, }, }, }) : updateKey.mutateAsync({ content: { 'application/json': { name: props.keyName, branch: branchParam, namespace: selectedNs || undefined, translations: newTranslations, states: newStates, screenshotIdsToDelete: getRemovedScreenshots(), screenshotsToAdd: getJustUploadedScreenshots().map((sc) => ({ uploadedImageId: sc.id, positions: sc.keyReferences?.map(mapPosition), })), tags, relatedKeysInOrder, isPlural, pluralArgName, maxCharLimit: maxCharLimit ?? 0, }, }, path: { id: keyData.keyId! }, })); changeInTolgeeCache( props.keyName, selectedNs, Object.entries(newTranslations), props.uiProps.changeTranslation ); props.uiProps.onPermanentChange({ key: props.keyName, namespace: selectedNs, }); translationsLoadable.refetch(); setSaving(false); setSuccess(true); if (useBrowserWindow) { await sleep(2000); setSuccess(false); } else { await sleep(400); props.onClose(); } } catch (e: any) { // eslint-disable-next-line no-console console.error(e); if ( e instanceof HttpError && e.code === 'operation_not_permitted_in_read_only_mode' ) { setReadOnly(true); } else { setSubmitError(e); } } finally { setSaving(false); setSuccess(false); } } function onClose() { if (screenshotDetail) { setScreenshotDetail(null); } else { props.onClose(); setUseBrowserWindow(false); const uploadedScreenshots = getJustUploadedScreenshots(); if (uploadedScreenshots.length) { deleteImages(uploadedScreenshots.map((sc) => sc.id!)); } setScreenshots([]); } } function onSelectedLanguagesChange(languages: string[]) { if (languages.length) { setSelectedLanguages(languages); setPreferredLanguages(languages); } } useEffect(() => { const onKeyDown = (e: any) => { if (e.key === 'Escape') { onClose(); } }; if (!useBrowserWindow) { window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('keydown', onKeyDown); }; } }, [useBrowserWindow]); const getJustUploadedScreenshots = () => { return screenshots.filter((sc) => sc.justUploaded); }; const getRemovedScreenshots = () => { return ( keyData?.screenshots ?.map((sc) => sc.id) .filter((scId) => !screenshots.find((sc) => sc.id === scId)) || [] ); }; function handleTakeScreenshot() { galleryProps.handleTakeScreenshot( props.keyName, selectedNs, Object.entries(translationsForm).map( ([language, value]) => [ language, tolgeeFormatGenerateIcu( { ...value.value, parameter: pluralArgName }, !icuPlaceholders ), ] as [string, string] ) ); } const versionError = checkPlatformVersion( MINIMAL_PLATFORM_VERSION, translationsLoadable.data?._internal?.version ); const baseLang = availableLanguages?.find(({ base }) => base); const loading = languagesLoadable.isFetching || (translationsLoadable.isLoading && !translationsLoadable.data) || scopesLoadable.isFetching; const error = versionError || languagesLoadable.error || translationsLoadable.error || scopesLoadable.error || createKey.error || updateKey.error || galleryError; const maxCharLimit = _maxCharLimit !== undefined ? _maxCharLimit : keyData?.keyMaxCharLimit; const charLimit = maxCharLimit; const isOverCharLimit = useMemo(() => { if (charLimit == null || charLimit <= 0) return false; return Object.values(translationsForm).some((entry) => { const variants = entry.value?.variants; if (!variants) return false; return Object.entries(variants).some( ([variant, text]) => getVisibleCharCount({ text, nested: variant !== 'other' }) > charLimit ); }); }, [translationsForm, charLimit]); const formDisabled = loading || !permissions.canSubmitForm || readOnly; const contextValue = { input: props.keyName, fallbackNamespaces: props.fallbackNamespaces, uiProps: props.uiProps, selectedNs, loading, saving, success, error, availableLanguages, selectedLanguages: putBaseLangFirstTags(selectedLanguages, baseLang?.tag), formDisabled, readOnly, keyData, translationsForm, container, useBrowserWindow, takingScreenshot, screenshotsUploading, screenshots, screenshotDetail, linkToPlatform, keyExists, maxCharLimit, tags: tags || [], permissions, canTakeScreenshots, isPlural, _pluralArgName, pluralArgName, pluralsSupported, icuPlaceholders, submitError, filterTagMissing, isOverCharLimit, } as const; const actions = { onInputChange, onStateChange, handleUploadImages, handleTakeScreenshot, handleRemoveScreenshot, onSave, onClose, onSelectedLanguagesChange, setContainer, setUseBrowserWindow, setScreenshotDetail, setSelectedNs, setTags, setIsPlural, setPluralArgName, setMaxCharLimit, }; return [contextValue, actions]; });