diff --git a/app/api/i18n/routes.ts b/app/api/i18n/routes.ts index 7382258053..56699b8ee0 100644 --- a/app/api/i18n/routes.ts +++ b/app/api/i18n/routes.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { createError, validation } from 'api/utils'; import settings from 'api/settings'; import entities from 'api/entities'; @@ -11,6 +12,7 @@ import { Application, Request } from 'express'; import { UITranslationNotAvailable } from 'api/i18n/defaultTranslations'; import needsAuthorization from '../auth/authMiddleware'; import translations from './translations'; +import { getTranslationsEntriesV2, upsertTranslationEntries } from './v2_support'; const addLanguage = async (language: LanguageSchema) => { const newSettings = await settings.addLanguage(language); @@ -59,6 +61,7 @@ async function deleteLanguage(key: LanguageISO6391, req: Request) { type TranslationsRequest = Request & { query: { context: string } }; +// eslint-disable-next-line max-statements export default (app: Application) => { app.get( '/api/translations', @@ -81,6 +84,14 @@ export default (app: Application) => { } ); + app.get('/api/translationsV2', async (_req: TranslationsRequest, res) => { + const translationsV2 = await getTranslationsEntriesV2(); + + const translationList = await translationsV2.all(); + + res.json(translationList); + }); + app.get('/api/languages', async (_req, res) => { res.json(await translations.availableLanguages()); }); @@ -158,6 +169,45 @@ export default (app: Application) => { } ); + app.post( + '/api/translationsV2', + needsAuthorization(), + validation.validateRequest({ + type: 'object', + properties: { + body: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + language: { type: 'string' }, + key: { type: 'string' }, + value: { type: 'string' }, + context: { + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string' }, + type: { type: 'string' }, + }, + required: ['id', 'label', 'type'], + }, + }, + required: ['language', 'key', 'value', 'context'], + }, + }, + }, + required: ['body'], + }), + async (req, res) => { + await upsertTranslationEntries(req.body); + req.sockets.emitToCurrentTenant('translationKeysChange', req.body); + res.status(200); + res.json({ success: true }); + } + ); + app.post( '/api/translations/populate', needsAuthorization(), diff --git a/app/api/i18n/specs/routes.spec.ts b/app/api/i18n/specs/routes.spec.ts index f885c0c03b..26be8fbe56 100644 --- a/app/api/i18n/specs/routes.spec.ts +++ b/app/api/i18n/specs/routes.spec.ts @@ -12,6 +12,7 @@ import { TestEmitSources, iosocket, setUpApp } from 'api/utils/testingRoutes'; import { availableLanguages } from 'shared/languagesList'; import { LanguageSchema } from 'shared/types/commonTypes'; import { UserRole } from 'shared/types/userSchema'; +import { TranslationContext, TranslationType } from 'shared/translationType'; import { DefaultTranslations } from '../defaultTranslations'; import { sortByLocale } from './sortByLocale'; @@ -229,6 +230,31 @@ describe('i18n translations routes', () => { }); }); + describe('api/translationsV2', () => { + it('should update the translations', async () => { + const response = await request(app) + .post('/api/translationsV2') + .send([ + { + language: 'es', + key: 'Search', + value: 'Búsqueda', + context: { + id: 'System', + label: 'User Interface', + type: 'Uwazi UI', + }, + }, + ]); + const systemTranslations = response.body + .find((language: TranslationType) => language.locale === 'es') + .contexts.find((context: TranslationContext) => context.id === 'System'); + expect(systemTranslations).toMatchObject({ + values: { Search: 'Búsqueda' }, + }); + }); + }); + describe('api/translations/languages', () => { describe('when successful', () => { let response: request.Response; diff --git a/app/api/i18n/v2_support.ts b/app/api/i18n/v2_support.ts index a2c4161d2b..5c51c2fbf5 100644 --- a/app/api/i18n/v2_support.ts +++ b/app/api/i18n/v2_support.ts @@ -110,7 +110,7 @@ export const createTranslationsV2 = async (translation: TranslationType) => { ).create(flattenTranslations(translation)); }; -export const upsertTranslationsV2 = async (translations: TranslationType[]) => { +export const upsertTranslationEntries = async (translations: CreateTranslationsData[]) => { const transactionManager = DefaultTransactionManager(); await new UpsertTranslationsService( DefaultTranslationsDataSource(transactionManager), @@ -120,12 +120,15 @@ export const upsertTranslationsV2 = async (translations: TranslationType[]) => { DefaultSettingsDataSource(transactionManager) ), transactionManager - ).upsert( - translations.reduce( - (flattened, t) => flattened.concat(flattenTranslations(t)), - [] - ) + ).upsert(translations); +}; + +export const upsertTranslationsV2 = async (translations: TranslationType[]) => { + const translationsToUpsert = translations.reduce( + (flattened, t) => flattened.concat(flattenTranslations(t)), + [] ); + return upsertTranslationEntries(translationsToUpsert); }; export const deleteTranslationsByContextIdV2 = async (contextId: string) => { @@ -159,10 +162,11 @@ export const getTranslationsV2ByLanguage = async (language: LanguageISO6391) => language ); +export const getTranslationsEntriesV2 = async () => + new GetTranslationsService(DefaultTranslationsDataSource(DefaultTransactionManager())).getAll(); + export const getTranslationsV2 = async () => - resultsToV1TranslationType( - new GetTranslationsService(DefaultTranslationsDataSource(DefaultTransactionManager())).getAll() - ); + resultsToV1TranslationType(await getTranslationsEntriesV2()); export const updateContextV2 = async ( context: CreateTranslationsData['context'], diff --git a/app/react/App/App.js b/app/react/App/App.js index 05d22199c6..6f2b04df11 100644 --- a/app/react/App/App.js +++ b/app/react/App/App.js @@ -11,6 +11,8 @@ import { socket } from 'app/socket'; import { NotificationsContainer } from 'V2/Components/UI'; import { Matomo, CleanInsights } from 'app/V2/Components/Analitycs'; import { settingsAtom } from 'V2/atoms/settingsAtom'; +import { TranslateModal } from 'app/I18N/components/TranslateModal'; +import { inlineEditAtom } from 'V2/atoms'; import Confirm from './Confirm'; import { Menu } from './Menu'; import { AppMainContext } from './AppMainContext'; @@ -26,6 +28,7 @@ import 'flowbite'; const App = ({ customParams }) => { const [showMenu, setShowMenu] = useState(false); + const [inlineEditState] = useAtom(inlineEditAtom); const [confirmOptions, setConfirmOptions] = useState({}); const [settings, setSettings] = useAtom(settingsAtom); @@ -101,6 +104,7 @@ const App = ({ customParams }) => { + {inlineEditState.inlineEdit && inlineEditState.context && } ); }; diff --git a/app/react/App/scss/layout/_header.scss b/app/react/App/scss/layout/_header.scss index ca0943a5e3..892e0c559c 100644 --- a/app/react/App/scss/layout/_header.scss +++ b/app/react/App/scss/layout/_header.scss @@ -1,4 +1,4 @@ -@use "sass:color"; +@use 'sass:color'; @import '../config/colors'; @import '../elements/tooltip'; @@ -254,14 +254,6 @@ header { } } - .live-on { - color: #88eacd; - } - - .live-off { - color: #ffe66b; - } - button.singleItem { padding-left: 1em; } diff --git a/app/react/App/sockets.js b/app/react/App/sockets.js index c1785eb0c2..3657c66aa1 100644 --- a/app/react/App/sockets.js +++ b/app/react/App/sockets.js @@ -1,7 +1,8 @@ import { actions } from 'app/BasicReducer'; -import { t, Translate } from 'app/I18N'; +import { t } from 'app/I18N'; import { notificationActions } from 'app/Notifications'; import { documentProcessed } from 'app/Uploads/actions/uploadsActions'; +import { atomStore, translationsAtom } from 'V2/atoms'; import { store } from '../store'; import { socket, reconnectSocket } from '../socket'; @@ -58,10 +59,27 @@ socket.on('thesauriDelete', thesauri => { store.dispatch(actions.remove('thesauris', { _id: thesauri.id })); }); -socket.on('translationsChange', translations => { - store.dispatch(actions.update('translations', translations, 'locale')); - t.resetCachedTranslation(); - Translate.resetCachedTranslation(); +socket.on('translationsChange', languageTranslations => { + const translations = atomStore.get(translationsAtom); + const modifiedLanguage = translations.find( + translation => translation.locale === languageTranslations.locale + ); + modifiedLanguage.contexts = languageTranslations.contexts; + atomStore.set(translationsAtom, translations); + //t.resetCachedTranslation(); + // Translate.resetCachedTranslation(); +}); + +socket.on('translationKeysChange', translationsEntries => { + const translations = atomStore.get(translationsAtom); + translationsEntries.forEach(item => { + const modifiedContext = translations + .find(translation => translation.locale === item.language) + .contexts.find(c => c.id && c.id === item.context.id); + modifiedContext.values[item.key] = item.value; + }); + atomStore.set(translationsAtom, translations); + //t.resetCachedTranslation(); }); socket.on('translationsInstallDone', () => { @@ -89,8 +107,8 @@ socket.on('translationsInstallError', errorMessage => { socket.on('translationsDelete', locale => { store.dispatch(actions.remove('translations', { locale }, 'locale')); - t.resetCachedTranslation(); - Translate.resetCachedTranslation(); + //t.resetCachedTranslation(); + // Translate.resetCachedTranslation(); }); socket.on('translationsDeleteDone', () => { diff --git a/app/react/App/styles/globals.css b/app/react/App/styles/globals.css index f1d3c6f4d5..097c8292dd 100644 --- a/app/react/App/styles/globals.css +++ b/app/react/App/styles/globals.css @@ -1,5 +1,113 @@ +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(99 102 241 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(99 102 241 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + /* -! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com +! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com */ /* @@ -887,114 +995,6 @@ input[type="range"]::-ms-fill-lower { font-family: 'Inter', sans-serif !important; } -*, ::before, ::after { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(99 102 241 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} - -::backdrop { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(99 102 241 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} - .container { width: 100%; } diff --git a/app/react/I18N/components/I18NMenu.tsx b/app/react/I18N/components/I18NMenu.tsx index 0e74a6e592..3562184e2b 100644 --- a/app/react/I18N/components/I18NMenu.tsx +++ b/app/react/I18N/components/I18NMenu.tsx @@ -1,28 +1,22 @@ +/* eslint-disable react/no-multi-comp */ /* eslint-disable react-hooks/rules-of-hooks */ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { bindActionCreators, Dispatch } from 'redux'; -import { connect, ConnectedProps } from 'react-redux'; -import { IImmutable } from 'shared/types/Immutable'; +import { Location, useLocation } from 'react-router-dom'; +import { useAtom, useAtomValue } from 'jotai'; +import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/react/20/solid'; import { LanguagesListSchema } from 'shared/types/commonTypes'; -import { Icon } from 'UI'; -import { actions, Translate, t } from 'app/I18N'; -import { IStore } from 'app/istore'; import { NeedAuthorization } from 'app/Auth'; import { useOnClickOutsideElement } from 'app/utils/useOnClickOutsideElementHook'; -import { Location, useLocation } from 'react-router-dom'; +import { inlineEditAtom, localeAtom, settingsAtom, userAtom } from 'V2/atoms'; +import { Translate } from './Translate'; +import t from '../t'; const locationSearch = (location: Location) => { const cleanSearch = location.search.split(/page=\d+|&page=\d+/).join(''); return cleanSearch === '?' ? '' : cleanSearch; }; -const prepareValues = ( - languageMap: IImmutable, - locale: string, - location: Location -) => { - const languages: LanguagesListSchema = languageMap.toJS(); - +const prepareValues = (languages: LanguagesListSchema, locale: string, location: Location) => { const selectedLanguage = languages.find(lang => lang.key === locale) || languages.find(lang => lang.default); @@ -37,28 +31,20 @@ const prepareValues = ( return { languages, selectedLanguage, urlLocation, path }; }; -const mapStateToProps = (state: IStore) => ({ - languages: state.settings.collection.get('languages'), - i18nmode: state.inlineEdit.get('inlineEdit'), - locale: state.locale, - user: state.user, -}); +const SVGCircle = ({ fill }: { fill: string }) => ( + + + +); -const mapDispatchToProps = (dispatch: Dispatch<{}>) => - bindActionCreators({ toggleInlineEdit: actions.toggleInlineEdit }, dispatch); +// eslint-disable-next-line max-statements +const I18NMenu = () => { + const [inlineEditState, setInlineEditState] = useAtom(inlineEditAtom); + const locale = useAtomValue(localeAtom); + const user = useAtomValue(userAtom); + const { languages: languageList } = useAtomValue(settingsAtom); -const connector = connect(mapStateToProps, mapDispatchToProps); - -type mappedProps = ConnectedProps; - -const i18NMenuComponent = ({ - languages: languageMap, - i18nmode, - user, - locale, - toggleInlineEdit, -}: mappedProps) => { - if (!languageMap || languageMap.size < 1 || (languageMap!.size <= 1 && !user.get('_id'))) { + if (!languageList || languageList.length < 1 || (languageList.length <= 1 && !user?._id)) { return
; } @@ -68,7 +54,7 @@ const i18NMenuComponent = ({ const [dropdownOpen, setDropdownOpen] = useState(false); const { languages, selectedLanguage, path, urlLocation } = prepareValues( - languageMap!, + languageList, locale, location ); @@ -88,21 +74,27 @@ const i18NMenuComponent = ({ return (
  • - {i18nmode && ( + {inlineEditState.inlineEdit && (
    @@ -112,7 +104,7 @@ const i18NMenuComponent = ({ )} - {!i18nmode && ( + {!inlineEditState.inlineEdit && (
      @@ -146,11 +138,19 @@ const i18NMenuComponent = ({ className="live-translate" type="button" onClick={() => { - toggleInlineEdit(); + setInlineEditState({ + inlineEdit: !inlineEditState.inlineEdit, + translationKey: '', + context: '', + }); setDropdownOpen(false); }} > - + {inlineEditState.inlineEdit ? ( + + ) : ( + + )} Live translate @@ -162,6 +162,4 @@ const i18NMenuComponent = ({ ); }; -const container = connector(i18NMenuComponent); - -export { container as i18NMenuComponent }; +export { I18NMenu }; diff --git a/app/react/I18N/components/Translate.js b/app/react/I18N/components/Translate-old.js similarity index 100% rename from app/react/I18N/components/Translate.js rename to app/react/I18N/components/Translate-old.js diff --git a/app/react/I18N/components/Translate.tsx b/app/react/I18N/components/Translate.tsx new file mode 100644 index 0000000000..d121c7b874 --- /dev/null +++ b/app/react/I18N/components/Translate.tsx @@ -0,0 +1,82 @@ +/* eslint-disable max-statements */ +import React, { Fragment, ReactNode } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { translationsAtom, inlineEditAtom, localeAtom } from 'V2/atoms'; + +const parseMarkdownMarker = ( + line: string, + regexp: RegExp, + wrapper: (text: string) => ReactNode +) => { + const matches = line.match(regexp); + if (matches == null) { + return matches; + } + const parts = matches.input?.split(matches[0]); + return ( + <> + {parts?.length && parts[0]} + {wrapper(matches[1])} + {parts?.length && parts[1]} + + ); +}; + +const parseMarkdownBoldMarker = (line: string) => + parseMarkdownMarker(line, /\*{2}(.*)\*{2}/, text => {text}); + +const parseMarkdownItalicMarker = (line: string) => + parseMarkdownMarker(line, /\*(.*)\*/, text => {text}); + +type TranslateProps = { + className?: string; + children?: string; + context?: string; + translationKey?: string; +}; + +const Translate = ({ className, children, context = 'System', translationKey }: TranslateProps) => { + const translations = useAtomValue(translationsAtom); + const locale = useAtomValue(localeAtom); + const [inlineEditState, setInlineEditState] = useAtom(inlineEditAtom); + + const language = translations.find(translation => translation.locale === locale); + const activeClassName = inlineEditState.inlineEdit ? 'translation active' : 'translation'; + + const translationContext = language?.contexts.find(ctx => ctx.id === context) || { values: {} }; + const text = translationContext.values[(translationKey || children)!] || children; + const lines = text ? text.split('\n') : []; + + return ( + { + if (inlineEditState.inlineEdit) { + event.stopPropagation(); + event.preventDefault(); + setInlineEditState({ + inlineEdit: inlineEditState.inlineEdit, + context, + translationKey: (translationKey || children)!, + }); + } + }} + className={`${activeClassName} ${className}`} + > + {lines.map((line, index) => { + const boldMatches = parseMarkdownBoldMarker(line); + const italicMatches = parseMarkdownItalicMarker(line); + return ( + + {boldMatches || + italicMatches || ( // eslint-disable-next-line react/jsx-no-useless-fragment + <>{line} + )} + {index < lines.length - 1 &&
      } +
      + ); + })} +
      + ); +}; + +export { Translate }; diff --git a/app/react/I18N/components/TranslateModal.tsx b/app/react/I18N/components/TranslateModal.tsx new file mode 100644 index 0000000000..090779eb29 --- /dev/null +++ b/app/react/I18N/components/TranslateModal.tsx @@ -0,0 +1,96 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { Modal } from 'V2/Components/UI'; +import { settingsAtom, translationsAtom, inlineEditAtom } from 'V2/atoms'; +import { InputField } from 'app/V2/Components/Forms'; +import { Button } from 'V2/Components/UI/Button'; +import { TranslationValue } from 'V2/shared/types'; +import { postV2 } from 'V2/api/translations'; +import { Translate } from './Translate'; +import { t } from '..'; + +const TranslateModal = () => { + const [inlineEditState, setInlineEditState] = useAtom(inlineEditAtom); + const [translations] = useAtom(translationsAtom); + const context = translations[0].contexts.find(ctx => ctx.id === inlineEditState.context)!; + const { languages = [] } = useAtomValue(settingsAtom); + + const { register, handleSubmit, control, reset } = useForm<{ data: TranslationValue[] }>({ + mode: 'onSubmit', + }); + + const { fields } = useFieldArray({ control, name: 'data' }); + + React.useEffect(() => { + const initialValues = translations.map(translation => { + const language = languages.find(lang => lang.key === translation.locale)!; + const languageContext = translation.contexts.find(c => c.id === context.id)!; + const value = languageContext.values[inlineEditState.translationKey]; + return { + language: language.key, + value, + key: inlineEditState.translationKey, + }; + }); + reset({ data: initialValues }); + }, [context.id, context.values, inlineEditState.translationKey, languages, reset, translations]); + + const closeModal = () => { + setInlineEditState({ inlineEdit: true, translationKey: '', context: '' }); + }; + + const submit = async ({ data }: { data: TranslationValue[] }) => { + await postV2(data, context!); + closeModal(); + }; + + return ( + inlineEditState.context && ( +
      +
      + +
      + + {t('System', 'Translate', 'Translate', false)} + + {t('System', 'Close', 'Close', false)} + + + + {fields?.map((field, index) => ( + + {field.language.toUpperCase()} + + } + id={field.id} + key={field.id} + {...register(`data.${index}.value`)} + /> + ))} + + + + + +
      +
      +
      +
      + ) + ); +}; + +export { TranslateModal }; diff --git a/app/react/I18N/components/specs/I18NMenu.spec.tsx b/app/react/I18N/components/specs/I18NMenu.spec.tsx index f8cbb92073..aa3df508ab 100644 --- a/app/react/I18N/components/specs/I18NMenu.spec.tsx +++ b/app/react/I18N/components/specs/I18NMenu.spec.tsx @@ -1,16 +1,50 @@ /** * @jest-environment jsdom */ -import React from 'react'; -import { act, fireEvent, RenderResult, screen } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; +import React, { ReactNode } from 'react'; +import { act, fireEvent, RenderResult, screen, render } from '@testing-library/react'; import { Location, MemoryRouter } from 'react-router-dom'; -import Immutable from 'immutable'; -import { defaultState, renderConnectedContainer } from 'app/utils/test/renderConnected'; -import { i18NMenuComponent as I18NMenu } from '../I18NMenu'; +import { Provider } from 'jotai'; +import { inlineEditAtom, localeAtom, settingsAtom, userAtom } from 'V2/atoms'; +import { useHydrateAtoms } from 'jotai/utils'; +import { I18NMenu } from '../I18NMenu'; + +type TestProviderProps = { + initialValues: any[]; + children: ReactNode; +}; + +const HydrateAtoms = ({ initialValues, children }: TestProviderProps) => { + useHydrateAtoms(initialValues); + return children; +}; + +const TestAtomStoreProvider = ({ initialValues, children }: TestProviderProps) => ( + + {children} + +); describe('I18NMenu', () => { + const settingsAtomValue = { + languages: [ + { _id: '1', label: 'English', key: 'en', default: true }, + { _id: '2', label: 'Spanish', key: 'es', default: false }, + ], + }; + it('should render', () => { + render( + + + + + + ); + screen.debug(); + }); +}); + +/*describe('I18NMenu', () => { let props: any; let renderResult: RenderResult; let store: MockStoreEnhanced; @@ -169,3 +203,4 @@ describe('I18NMenu', () => { expect(window.location.assign).toHaveBeenCalledWith('/templates/2452345'); }); }); +*/ diff --git a/app/react/I18N/index.js b/app/react/I18N/index.js index 28c9b81aff..8990deb04b 100644 --- a/app/react/I18N/index.js +++ b/app/react/I18N/index.js @@ -1,21 +1,11 @@ -import { i18NMenuComponent } from './components/I18NMenu'; import I18NLink from './components/I18NLink'; import I18N from './components/I18N'; -import Translate from './components/Translate'; +import { Translate } from './components/Translate'; import TranslateForm from './components/TranslateForm'; import t from './t'; import I18NUtils from './utils'; import I18NApi from './I18NApi'; import * as actions from './actions/I18NActions'; -export { - i18NMenuComponent as I18NMenu, - I18NLink, - I18NUtils, - I18N, - Translate, - TranslateForm, - t, - I18NApi, - actions, -}; +export { I18NLink, I18NUtils, I18N, Translate, TranslateForm, t, I18NApi, actions }; +export { I18NMenu } from './components/I18NMenu'; diff --git a/app/react/I18N/t.js b/app/react/I18N/t.js index 1715fba354..5f663b181c 100644 --- a/app/react/I18N/t.js +++ b/app/react/I18N/t.js @@ -1,10 +1,11 @@ -import { store } from 'app/store'; +import { atomStore, translationsAtom, localeAtom } from 'V2/atoms'; import React from 'react'; import translate, { getLocaleTranslation, getContext } from '../../shared/translate'; import { Translate } from '.'; const testingEnvironment = process.env.NODE_ENV === 'test'; +// eslint-disable-next-line max-statements const t = (contextId, key, _text, returnComponent = true) => { if (!contextId) { // eslint-disable-next-line no-console @@ -15,21 +16,27 @@ const t = (contextId, key, _text, returnComponent = true) => { return {key}; } + let translations; + let locale; + + const updateTranslations = () => { + translations = atomStore.get(translationsAtom); + locale = atomStore.get(localeAtom); + t.translation = getLocaleTranslation(translations, locale); + return { translations, locale }; + }; + const text = _text || key; - if (!t.translation) { - const state = store.getState(); - const translations = state.translations.toJS(); - t.translation = getLocaleTranslation(translations, state.locale); - } + updateTranslations(); + + atomStore.sub(translationsAtom, () => { + updateTranslations(); + }); const context = getContext(t.translation, contextId); return translate(context, key, text); }; -t.resetCachedTranslation = () => { - t.translation = null; -}; - export default t; diff --git a/app/react/V2/Routes/Settings/ActivityLog/components/FiltersSidePanel.tsx b/app/react/V2/Routes/Settings/ActivityLog/components/FiltersSidePanel.tsx index cdecc22c79..fa7e848d4f 100644 --- a/app/react/V2/Routes/Settings/ActivityLog/components/FiltersSidePanel.tsx +++ b/app/react/V2/Routes/Settings/ActivityLog/components/FiltersSidePanel.tsx @@ -6,7 +6,7 @@ import { Translate, t } from 'app/I18N'; import { InputField, DateRangePicker, MultiSelect } from 'app/V2/Components/Forms'; import { useAtomValue } from 'jotai'; import { ClientSettings } from 'app/apiResponseTypes'; -import { settingsAtom, translationsAtom } from 'app/V2/atoms'; +import { settingsAtom, localeAtom } from 'app/V2/atoms'; interface ActivityLogSearch { username: string; @@ -36,7 +36,7 @@ const methodOptions = ['CREATE', 'UPDATE', 'DELETE', 'MIGRATE', 'WARNING'].map(m const FiltersSidePanel = ({ isOpen, onClose, onSubmit, appliedFilters }: FiltersSidePanelProps) => { const { dateFormat = 'YYYY-MM-DD' } = useAtomValue(settingsAtom); - const { locale } = useAtomValue<{ locale: string }>(translationsAtom); + const locale = useAtomValue(localeAtom); const [currentFilters, setCurrentFilters] = useState(appliedFilters); useEffect(() => { diff --git a/app/react/V2/api/translations/index.ts b/app/react/V2/api/translations/index.ts index 66a2c9026e..652c95d435 100644 --- a/app/react/V2/api/translations/index.ts +++ b/app/react/V2/api/translations/index.ts @@ -1,8 +1,10 @@ import { Params } from 'react-router-dom'; import { IncomingHttpHeaders } from 'http'; +import api from 'app/utils/api'; import { I18NApi } from 'app/I18N'; -import { ClientTranslationSchema } from 'app/istore'; +import { ClientTranslationSchema, ClientTranslationContextSchema } from 'app/istore'; import { RequestParams } from 'app/utils/RequestParams'; +import { TranslationValue } from 'V2/shared/types'; import { httpRequest } from 'shared/superagent'; import loadingBar from 'app/App/LoadingProgressBar'; @@ -24,7 +26,19 @@ const get = async ( return response; }; -const post = async (updatedTranslations: ClientTranslationSchema[], contextId: string) => { +const getV2 = async ( + headers?: IncomingHttpHeaders, + parameters?: Params +): Promise => { + const params = new RequestParams(parameters, headers); + const response = api.get('translationsV2', params); + return response; +}; + +const post = async ( + updatedTranslations: ClientTranslationSchema[], + contextId: string +): Promise => { try { const translations = await Promise.all( updatedTranslations.map(language => I18NApi.save(new RequestParams(language))) @@ -35,6 +49,21 @@ const post = async (updatedTranslations: ClientTranslationSchema[], contextId: s } }; +const postV2 = async ( + updatedTranslations: TranslationValue[], + context: ClientTranslationContextSchema, + headers?: IncomingHttpHeaders +): Promise => { + try { + const translations = updatedTranslations.map(ut => ({ ...ut, context })); + const params = new RequestParams(translations, headers); + const response = await api.post('translationsV2', params); + return response.status.ok; + } catch (e) { + return e; + } +}; + const importTranslations = async ( file: File, contextId: string @@ -60,4 +89,4 @@ const importTranslations = async ( const { getLanguages } = I18NApi; -export { get, post, importTranslations, getLanguages }; +export { get, getV2, post, postV2, importTranslations, getLanguages }; diff --git a/app/react/V2/atoms/index.ts b/app/react/V2/atoms/index.ts index c79e481091..891bb8973d 100644 --- a/app/react/V2/atoms/index.ts +++ b/app/react/V2/atoms/index.ts @@ -2,7 +2,7 @@ export { atomStore, hydrateAtomStore } from './store'; export { notificationAtom } from './notificationAtom'; export { settingsAtom } from './settingsAtom'; export { templatesAtom } from './templatesAtom'; -export { translationsAtom } from './translationsAtom'; +export { translationsAtom, inlineEditAtom, localeAtom } from './translationsAtoms'; export { thesauriAtom } from './thesauriAtom'; export { globalMatomoAtom } from './globalMatomoAtom'; export { ciMatomoActiveAtom } from './ciMatomoActiveAtom'; diff --git a/app/react/V2/atoms/store.ts b/app/react/V2/atoms/store.ts index 8a155dc658..2fbf9657a7 100644 --- a/app/react/V2/atoms/store.ts +++ b/app/react/V2/atoms/store.ts @@ -2,13 +2,13 @@ import { createStore } from 'jotai'; import { isClient } from 'app/utils'; import { store } from 'app/store'; import { ClientSettings, ClientThesaurus, ClientUserSchema } from 'app/apiResponseTypes'; -import { ClientTemplateSchema } from 'app/istore'; +import { ClientTemplateSchema, ClientTranslationSchema } from 'app/istore'; import { globalMatomoAtom } from './globalMatomoAtom'; import { ciMatomoActiveAtom } from './ciMatomoActiveAtom'; import { relationshipTypesAtom } from './relationshipTypes'; import { settingsAtom } from './settingsAtom'; import { templatesAtom } from './templatesAtom'; -import { translationsAtom } from './translationsAtom'; +import { translationsAtom, localeAtom } from './translationsAtoms'; import { userAtom } from './userAtom'; import { thesauriAtom } from './thesauriAtom'; @@ -20,6 +20,7 @@ type AtomStoreData = { templates?: ClientTemplateSchema[]; user?: ClientUserSchema; ciMatomoActive?: boolean; + translations: ClientTranslationSchema[]; }; declare global { @@ -37,7 +38,8 @@ const hydrateAtomStore = (data: AtomStoreData) => { if (data.thesauri) atomStore.set(thesauriAtom, data.thesauri); if (data.templates) atomStore.set(templatesAtom, data.templates); atomStore.set(userAtom, data.user); - atomStore.set(translationsAtom, { locale: data.locale || 'en' }); + atomStore.set(translationsAtom, data.translations); + atomStore.set(localeAtom, data.locale || 'en'); }; if (isClient && window.__atomStoreData__) { diff --git a/app/react/V2/atoms/templatesAtom.tsx b/app/react/V2/atoms/templatesAtom.ts similarity index 100% rename from app/react/V2/atoms/templatesAtom.tsx rename to app/react/V2/atoms/templatesAtom.ts diff --git a/app/react/V2/atoms/translationsAtom.tsx b/app/react/V2/atoms/translationsAtom.tsx deleted file mode 100644 index 40e8f13386..0000000000 --- a/app/react/V2/atoms/translationsAtom.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { atom } from 'jotai'; - -const translationsAtom = atom({ locale: '' }); - -export { translationsAtom }; diff --git a/app/react/V2/atoms/translationsAtoms.ts b/app/react/V2/atoms/translationsAtoms.ts new file mode 100644 index 0000000000..463a4cd2d4 --- /dev/null +++ b/app/react/V2/atoms/translationsAtoms.ts @@ -0,0 +1,8 @@ +import { atom } from 'jotai'; +import { ClientTranslationSchema } from 'app/istore'; + +const translationsAtom = atom([] as ClientTranslationSchema[]); +const localeAtom = atom(''); +const inlineEditAtom = atom({ inlineEdit: false, context: '', translationKey: '' }); + +export { translationsAtom, inlineEditAtom, localeAtom }; diff --git a/app/react/V2/shared/types.ts b/app/react/V2/shared/types.ts index 14c9ae005a..9bd66c2fee 100644 --- a/app/react/V2/shared/types.ts +++ b/app/react/V2/shared/types.ts @@ -10,6 +10,12 @@ type DraggableValue = T & { items?: IDraggable[]; }; +type TranslationValue = { + language: string; + key: string; + value: string; +}; + interface IDraggable { dndId?: string; value: DraggableValue; @@ -50,4 +56,12 @@ enum ItemTypes { } export { ItemTypes }; -export type { IXExtractorInfo, ISublink, ILink, IDraggable, DraggableValue, Page }; +export type { + IXExtractorInfo, + ISublink, + ILink, + IDraggable, + DraggableValue, + Page, + TranslationValue, +}; diff --git a/app/react/entry-server.tsx b/app/react/entry-server.tsx index 4780605e83..07de1199dc 100644 --- a/app/react/entry-server.tsx +++ b/app/react/entry-server.tsx @@ -154,7 +154,6 @@ const prepareStores = async (req: ExpressRequest, settings: ClientSettings, lang const reduxData = { user: userApiResponse.json, - translations: translationsApiResponse.json.rows, templates: templatesApiResponse.json.rows, thesauris: thesaurisApiResponse.json.rows, relationTypes: relationTypesApiResponse.json.rows, @@ -176,6 +175,7 @@ const prepareStores = async (req: ExpressRequest, settings: ClientSettings, lang thesauri: thesaurisApiResponse.json.rows, templates: templatesApiResponse.json.rows, user: userApiResponse.json, + translations: translationsApiResponse.json.rows, }, }; }; @@ -268,10 +268,10 @@ const getSSRProperties = async ( }; }; -const resetTranslations = () => { +/*const resetTranslations = () => { t.resetCachedTranslation(); Translate.resetCachedTranslation(); -}; +};*/ const EntryServer = async (req: ExpressRequest, res: Response) => { RouteHandler.renderedFromServer = true; @@ -303,7 +303,7 @@ const EntryServer = async (req: ExpressRequest, res: Response) => { reduxState, matched ); - resetTranslations(); + //resetTranslations(); hydrateAtomStore(atomStoreData); const componentHtml = ReactDOMServer.renderToString( diff --git a/app/shared/translate.js b/app/shared/translate.js index 0cd87d33ac..d52c4d828f 100644 --- a/app/shared/translate.js +++ b/app/shared/translate.js @@ -1,10 +1,8 @@ -/** @format */ - -export function getLocaleTranslation(translations, locale) { +function getLocaleTranslation(translations, locale) { return translations.find(d => d.locale === locale) || { contexts: [] }; } -export function getContext(translation, contextId = '') { +function getContext(translation, contextId = '') { return ( translation.contexts.find(ctx => ctx.id.toString() === contextId.toString()) || { values: {} } ); @@ -13,3 +11,5 @@ export function getContext(translation, contextId = '') { export default function translate(context, key, text) { return context.values[key] || text; } + +export { getLocaleTranslation, getContext }; diff --git a/tailwind.config.js b/tailwind.config.js index 028918c9b4..6aeeb98522 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,7 +3,7 @@ const colors = require('tailwindcss/colors'); module.exports = { content: [ - './app/react/V2/**/*.{js,jsx,ts,tsx}', + './app/react/**/*.{js,jsx,ts,tsx}', './app/react/stories/**/*.{js,jsx,ts,tsx}', 'node_modules/flowbite-react/**/*.{js,jsx,ts,tsx}', 'node_modules/flowbite-datepicker/**/*.{js,jsx,ts,tsx,css}',