feat(i18n): Add ability to translate the entire page including momentjs and Ant Design (#50)
All checks were successful
/ build (push) Successful in 5m22s

Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
This commit is contained in:
Varakh 2024-12-01 10:38:43 +00:00 committed by Varakh
parent ac1ae19e33
commit 5c089989cf
17 changed files with 331 additions and 29 deletions

View file

@ -30,6 +30,74 @@ Use the `npm run start` command to start the development setup. Backend should b
Pipeline checks for existing i18n keys, if you need to manually sync any language file, run `npm run i18n-sync`.
#### New translation languages
Adapt `package.json` and add the respective key to `i18n-sync` and `checkstyle:i18n` for the `--languages` argument,
e.g. `--languages en,new`.
Run `npm run i18n-sync` to create and sync new language
Adapt `languages.ts`
```typescript
enum Languages {
// ...
NEW = 'new'
}
```
Adapt `locales.ts`
```typescript
enum Locales {
// ...
NEW = 'new-NEW'
}
```
Adapt `LocaleProvider.tsx` to detect the new language, assign proper _momentjs_ locale.
```typescript
// add import for Ant Design
import new_NEW from 'antd/lib/locale/new_NEW';
// add import for moment
// @ts-ignore
import new_localization from 'moment/dist/locale/new.js';
// getLocaleFromLanguage
if (LANGUAGES.NEW === language) {
// ...
}
// getLanguageFromLocale
if (LANGUAGES.NEW === language) {
// ...
}
// setLocales (uses import of moment)
// ...
else if (Languages.NEW === language) {
// use import for Ant Design
setAntLocale(new_NEW);
// use import for moment
moment.updateLocale(language, de_localization);
}
```
Adapt `i18n/index.ts` and import new language and make i18n detect it (locales are derived from it)
```typescript jsx
import newTranslation from './translations/new.json';
const resources = {
//...
new: {
...newTranslation
}
};
```
### Dependencies
To track unused dependencies

View file

@ -26,8 +26,8 @@
"checkstyle:format": "npm run format:check",
"checkstyle:ts": "eslint \"./src/**/*.{ts,tsx}\" -f checkstyle > ci/eslint.xml",
"checkstyle:less": "stylelint \"./src/**/*.less\"",
"checkstyle:i18n": "sync-i18n --check --files '**/translations/*.json' --primary en --space 2 --lineendings LF",
"i18n-sync": "sync-i18n --files '**/translations/*.json' --primary en --space 2 --lineendings LF",
"checkstyle:i18n": "sync-i18n --check --files '**/translations/*.json' --primary en --languages en --space 2 --lineendings LF",
"i18n-sync": "sync-i18n --files '**/translations/*.json' --primary en --languages en --space 2 --lineendings LF",
"clean": "npx --quiet rimraf build && npx --quiet rimraf node_modules"
},
"dependencies": {

View file

@ -0,0 +1,8 @@
enum DateTimeStyle {
FULL = 'full',
LONG = 'long',
MEDIUM = 'medium',
SHORT = 'short'
}
export default DateTimeStyle;

View file

@ -0,0 +1,6 @@
enum Languages {
ENGLISH = 'en',
DEFAULT = ENGLISH
}
export default Languages;

View file

@ -0,0 +1,6 @@
enum Locales {
ENGLISH_US = 'en-US',
DEFAULT = ENGLISH_US
}
export default Locales;

View file

@ -17,7 +17,6 @@ i18next
.init(
{
resources,
lng: 'en',
fallbackLng: 'en',
debug: isDevelopment(),
interpolation: {

View file

@ -209,6 +209,7 @@
"back_home": "Go back",
"events": "Events",
"home": "Home",
"loading": "Loading...",
"login": "Login",
"logout": "Logout",
"secrets": "Secrets",

View file

@ -1,5 +1,6 @@
import './i18n';
import App from './App';
import LocaleContextProvider from './providers/LocaleContextProvider';
import store from './store';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
@ -15,7 +16,9 @@ root.render(
* Fix not yet available. follow thread: https://github.com/ant-design/ant-design/issues/22493 */}
{/*<React.StrictMode>*/}
<Router>
<App />
<LocaleContextProvider>
<App />
</LocaleContextProvider>
</Router>
{/*</React.StrictMode>*/}
</Provider>

View file

@ -2,7 +2,9 @@ import ItemActionInvocation from './ItemActionInvocation';
import { useGetActionInvocationsQuery } from '../../api/actionInvocationsApi';
import ActionInvocationFilterQueryParamNames from '../../constants/api/actionInvocationFilterQueryParamNames';
import ActionInvocationOrder from '../../constants/api/actionInvocationOrder';
import DateTimeStyle from '../../constants/dateTimeStyle';
import { PAGE_DEFAULT, PAGE_DEFAULT_OPTIONS, PAGE_SIZE_DEFAULT } from '../../constants/pagination';
import { useLocaleProviderContext } from '../../providers/LocaleContextProvider';
import { ActionInvocationResponse, ActionInvocationsRequestParams, ActionInvocationState } from '../../types';
import useActionInvocationsFilterQueryParams from '../../use/useActionInvocationsFilterQueryParams';
import { convertToLowerCaseUnderscore } from '../../utils/apiHelper';
@ -30,6 +32,7 @@ const DEFAULT_POLLING_INTERVAL = 5000;
const ActionInvocationsPage = () => {
const [t] = useTranslation('action_invocations');
const { locale } = useLocaleProviderContext();
const [pollingInterval, setPollingInterval] = useState<number>(DEFAULT_POLLING_INTERVAL);
const [queryParams, setSearchQueryParams] = useSearchParams();
@ -172,7 +175,7 @@ const ActionInvocationsPage = () => {
ellipsis: true,
responsive: ['sm', 'md', 'lg', 'xl', 'xxl'],
sorter: (a, b) => sortAlphaIgnoringCase(a.createdAt, b.createdAt),
render: (value) => formatDateTimeWithTimeZone(value)
render: (value) => formatDateTimeWithTimeZone(value, DateTimeStyle.LONG, DateTimeStyle.MEDIUM, locale)
},
{
title: t('col_updated_at'),
@ -181,10 +184,10 @@ const ActionInvocationsPage = () => {
ellipsis: true,
responsive: ['sm', 'md', 'lg', 'xl', 'xxl'],
sorter: (a, b) => sortAlphaIgnoringCase(a.updatedAt, b.updatedAt),
render: (value) => formatDateTimeWithTimeZone(value)
render: (value) => formatDateTimeWithTimeZone(value, DateTimeStyle.LONG, DateTimeStyle.MEDIUM, locale)
}
];
}, [renderState, t]);
}, [locale, renderState, t]);
return (
<>

View file

@ -6,7 +6,9 @@ import UpdateLabelAction from './UpdateLabelAction';
import { useGetActionsQuery } from '../../api/actionsApi';
import ActionFilterQueryParamNames from '../../constants/api/actionFilterQueryParamNames';
import ActionOrder from '../../constants/api/actionOrder';
import DateTimeStyle from '../../constants/dateTimeStyle';
import { PAGE_DEFAULT, PAGE_DEFAULT_OPTIONS, PAGE_SIZE_DEFAULT } from '../../constants/pagination';
import { useLocaleProviderContext } from '../../providers/LocaleContextProvider';
import { ActionResponse, ActionsRequestParams, ActionType } from '../../types';
import useActionsFilterQueryParams from '../../use/useActionsFilterQueryParams';
import { convertToLowerCaseUnderscore } from '../../utils/apiHelper';
@ -27,6 +29,7 @@ const DEFAULT_POLLING_INTERVAL = 5000;
const ActionsPage = () => {
const [t] = useTranslation('actions');
const { locale } = useLocaleProviderContext();
const [pollingInterval, setPollingInterval] = useState<number>(0);
const [queryParams, setSearchQueryParams] = useSearchParams();
@ -135,7 +138,7 @@ const ActionsPage = () => {
ellipsis: true,
responsive: ['sm', 'md', 'lg', 'xl', 'xxl'],
sorter: (a, b) => sortAlphaIgnoringCase(a.createdAt, b.createdAt),
render: (value) => formatDateTimeWithTimeZone(value)
render: (value) => formatDateTimeWithTimeZone(value, DateTimeStyle.LONG, DateTimeStyle.MEDIUM, locale)
},
{
title: t('col_updated_at'),
@ -144,10 +147,10 @@ const ActionsPage = () => {
ellipsis: true,
responsive: ['sm', 'md', 'lg', 'xl', 'xxl'],
sorter: (a, b) => sortAlphaIgnoringCase(a.updatedAt, b.updatedAt),
render: (value) => formatDateTimeWithTimeZone(value)
render: (value) => formatDateTimeWithTimeZone(value, DateTimeStyle.LONG, DateTimeStyle.MEDIUM, locale)
}
];
}, [t]);
}, [locale, t]);
return (
<>

View file

@ -1,5 +1,7 @@
import EventText from './EventText';
import { useDeleteEventMutation } from '../../api/eventsApi';
import DateTimeStyle from '../../constants/dateTimeStyle';
import { useLocaleProviderContext } from '../../providers/LocaleContextProvider';
import { EventResponse } from '../../types/event';
import { formatDateTimeWithTimeZone } from '../../utils/datetimeHelper';
import { apiNotification } from '../common/apiNotification';
@ -17,6 +19,7 @@ export interface EventProps {
const Event: FC<EventProps> = ({ entity, onDeleteSuccess }): JSX.Element => {
const [t] = useTranslation('event');
const { locale } = useLocaleProviderContext();
const [deleteEvent, { isSuccess, isLoading, isError, error }] = useDeleteEventMutation();
@ -44,7 +47,7 @@ const Event: FC<EventProps> = ({ entity, onDeleteSuccess }): JSX.Element => {
size="small"
actions={[
<Text key={`${entity.id}_created`} italic type="secondary">
{formatDateTimeWithTimeZone(entity.createdAt)}
{formatDateTimeWithTimeZone(entity.createdAt, DateTimeStyle.LONG, DateTimeStyle.MEDIUM, locale)}
</Text>,
<Popconfirm
key={`${entity.id}_del_confirm`}

View file

@ -2,6 +2,8 @@ import CreateSecret from './CreateSecret';
import DeleteSecret from './DeleteSecret';
import UpdateValueSecret from './UpdateValueSecret';
import { useGetSecretsQuery } from '../../api/secretsApi';
import DateTimeStyle from '../../constants/dateTimeStyle';
import { useLocaleProviderContext } from '../../providers/LocaleContextProvider';
import { SecretResponse } from '../../types';
import { formatDateTimeWithTimeZone } from '../../utils/datetimeHelper';
import { sortAlphaIgnoringCase } from '../../utils/sortHelper';
@ -18,6 +20,7 @@ const { Text } = Typography;
const SecretsPage: FC = () => {
const [t] = useTranslation('secrets');
const { locale } = useLocaleProviderContext();
const { isLoading, isError, refetch, isFetching, isSuccess, data } = useGetSecretsQuery();
@ -52,7 +55,7 @@ const SecretsPage: FC = () => {
ellipsis: true,
responsive: ['sm', 'md', 'lg', 'xl', 'xxl'],
sorter: (a, b) => sortAlphaIgnoringCase(a.createdAt, b.createdAt),
render: (value) => formatDateTimeWithTimeZone(value)
render: (value) => formatDateTimeWithTimeZone(value, DateTimeStyle.LONG, DateTimeStyle.MEDIUM, locale)
},
{
title: t('col_updated_at'),
@ -61,7 +64,7 @@ const SecretsPage: FC = () => {
ellipsis: true,
responsive: ['sm', 'md', 'lg', 'xl', 'xxl'],
sorter: (a, b) => sortAlphaIgnoringCase(a.updatedAt, b.updatedAt),
render: (value) => formatDateTimeWithTimeZone(value)
render: (value) => formatDateTimeWithTimeZone(value, DateTimeStyle.LONG, DateTimeStyle.MEDIUM, locale)
},
{
title: t('actions'),
@ -73,7 +76,7 @@ const SecretsPage: FC = () => {
}
}
];
}, [t]);
}, [locale, t]);
return (
<>

View file

@ -1,6 +1,8 @@
import UpdateStateTag from './UpdateStateTag';
import { useDeleteUpdateMutation, useModifyUpdateStateMutation } from '../../api/updatesApi';
import AppPaths from '../../constants/appPaths';
import DateTimeStyle from '../../constants/dateTimeStyle';
import { useLocaleProviderContext } from '../../providers/LocaleContextProvider';
import { UpdateResponse, UpdateState } from '../../types';
import { formatDateTimeWithTimeZone } from '../../utils/datetimeHelper';
import { getUpdateStateColor } from '../../utils/updateHelper';
@ -28,6 +30,7 @@ export interface UpdateProps {
const Update: FC<UpdateProps> = ({ entity }): JSX.Element => {
const [t] = useTranslation('update');
const { locale } = useLocaleProviderContext();
const navigate = useNavigate();
const redirectToDetails = useCallback(() => {
@ -178,7 +181,12 @@ const Update: FC<UpdateProps> = ({ entity }): JSX.Element => {
<Tooltip
placement={'bottom'}
title={t('created_at', {
created: formatDateTimeWithTimeZone(entity.createdAt)
created: formatDateTimeWithTimeZone(
entity.createdAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)
})}>
<FieldTimeOutlined />
</Tooltip>
@ -187,8 +195,18 @@ const Update: FC<UpdateProps> = ({ entity }): JSX.Element => {
<Tooltip
placement={'bottom'}
title={t('created_at_diff', {
created: formatDateTimeWithTimeZone(entity.createdAt),
updated: formatDateTimeWithTimeZone(entity.updatedAt)
created: formatDateTimeWithTimeZone(
entity.createdAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
),
updated: formatDateTimeWithTimeZone(
entity.updatedAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)
})}>
<FieldTimeOutlined />
</Tooltip>
@ -243,7 +261,14 @@ const Update: FC<UpdateProps> = ({ entity }): JSX.Element => {
<Text type="secondary" ellipsis>
{t('created')}
</Text>
<Text>{formatDateTimeWithTimeZone(entity.createdAt)}</Text>
<Text>
{formatDateTimeWithTimeZone(
entity.createdAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)}
</Text>
</Space>
</Col>
</Row>
@ -253,7 +278,14 @@ const Update: FC<UpdateProps> = ({ entity }): JSX.Element => {
<Text type="secondary" ellipsis>
{t('updated')}
</Text>
<Text>{formatDateTimeWithTimeZone(entity.updatedAt)}</Text>
<Text>
{formatDateTimeWithTimeZone(
entity.updatedAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)}
</Text>
</Space>
</Col>
</Row>

View file

@ -4,6 +4,8 @@ import { useGetUpdateByIdQuery } from '../../api/updatesApi';
import ApiErrorCodes from '../../constants/apiErrorCodes';
import AppPathParamNames from '../../constants/appPathParamNames';
import AppPaths from '../../constants/appPaths';
import DateTimeStyle from '../../constants/dateTimeStyle';
import { useLocaleProviderContext } from '../../providers/LocaleContextProvider';
import { formatDateTimeWithTimeZone } from '../../utils/datetimeHelper';
import { getPageFullPath } from '../../utils/urlHelper';
import AppBreadcrumb from '../common/AppBreadcrumb';
@ -17,6 +19,7 @@ const { Text } = Typography;
const UpdateSinglePage: FC = (): JSX.Element => {
const [t] = useTranslation('updates_single');
const { locale } = useLocaleProviderContext();
const { [AppPathParamNames.UPDATE_ID]: updateId } = useParams();
@ -76,10 +79,20 @@ const UpdateSinglePage: FC = (): JSX.Element => {
<Descriptions.Item label={t('host')}>{data.data.host}</Descriptions.Item>
<Descriptions.Item label={t('provider')}>{data.data.provider}</Descriptions.Item>
<Descriptions.Item label={t('created')}>
{formatDateTimeWithTimeZone(data.data.createdAt)}
{formatDateTimeWithTimeZone(
data.data.createdAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)}
</Descriptions.Item>
<Descriptions.Item label={t('updated')}>
{formatDateTimeWithTimeZone(data.data.updatedAt)}
{formatDateTimeWithTimeZone(
data.data.updatedAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)}
</Descriptions.Item>
</Descriptions>
<Descriptions

View file

@ -3,7 +3,9 @@ import {
useModifyIgnoreHostWebhookMutation,
useModifyLabelWebhookMutation
} from '../../api/webhooksApi';
import DateTimeStyle from '../../constants/dateTimeStyle';
import getConfiguration from '../../getConfiguration';
import { useLocaleProviderContext } from '../../providers/LocaleContextProvider';
import { WebhookResponse, WebhookType } from '../../types';
import { formatDateTimeWithTimeZone } from '../../utils/datetimeHelper';
import { apiNotification } from '../common/apiNotification';
@ -21,6 +23,7 @@ export interface WebhookProps {
const Webhook: FC<WebhookProps> = ({ entity }): JSX.Element => {
const [t] = useTranslation('webhook');
const { locale } = useLocaleProviderContext();
const [deleteWebhook, { isLoading: isDeleteLoading, isError: isErrorDelete, error: deleteError }] =
useDeleteWebhookMutation();
@ -123,7 +126,12 @@ const Webhook: FC<WebhookProps> = ({ entity }): JSX.Element => {
<Tooltip
placement={'bottom'}
title={t('created_at', {
created: formatDateTimeWithTimeZone(entity.createdAt)
created: formatDateTimeWithTimeZone(
entity.createdAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)
})}>
<FieldTimeOutlined />
</Tooltip>
@ -132,8 +140,18 @@ const Webhook: FC<WebhookProps> = ({ entity }): JSX.Element => {
<Tooltip
placement={'bottom'}
title={t('created_at_diff', {
created: formatDateTimeWithTimeZone(entity.createdAt),
updated: formatDateTimeWithTimeZone(entity.updatedAt)
created: formatDateTimeWithTimeZone(
entity.createdAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
),
updated: formatDateTimeWithTimeZone(
entity.updatedAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)
})}>
<FieldTimeOutlined />
</Tooltip>
@ -193,7 +211,14 @@ const Webhook: FC<WebhookProps> = ({ entity }): JSX.Element => {
<Text type="secondary" ellipsis>
{t('created')}
</Text>
<Text>{formatDateTimeWithTimeZone(entity.createdAt)}</Text>
<Text>
{formatDateTimeWithTimeZone(
entity.createdAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)}
</Text>
</Space>
</Col>
</Row>
@ -203,7 +228,14 @@ const Webhook: FC<WebhookProps> = ({ entity }): JSX.Element => {
<Text type="secondary" ellipsis>
{t('updated')}
</Text>
<Text>{formatDateTimeWithTimeZone(entity.updatedAt)}</Text>
<Text>
{formatDateTimeWithTimeZone(
entity.updatedAt,
DateTimeStyle.LONG,
DateTimeStyle.MEDIUM,
locale
)}
</Text>
</Space>
</Col>
</Row>

View file

@ -0,0 +1,111 @@
import Languages from '../constants/languages';
import Locales from '../constants/locales';
import { Col, ConfigProvider, Layout, Row, Spin } from 'antd';
import { Locale } from 'antd/lib/locale';
import en_US from 'antd/lib/locale/en_US';
// vite cannot bundle locales of moment, see https://github.com/moment/moment/issues/5926
import moment from 'moment-timezone';
import React, { createContext, FC, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
export type LocaleProviderContextType = {
language: string;
locale: string;
};
type LocaleProviderProviderProps = {
children: ReactNode | ReactNode[];
};
const LocaleContext = createContext<LocaleProviderContextType | undefined>(undefined);
const LocaleContextProvider: FC<LocaleProviderProviderProps> = ({ children }): React.JSX.Element => {
const { i18n, t } = useTranslation();
const [antLocale, setAntLocale] = useState<Locale>();
const [language, setLanguage] = useState<string>(Languages.DEFAULT);
const [locale, setLocale] = useState<string>(Locales.DEFAULT);
const isLocale = useCallback((i18nLanguage: string) => {
return i18nLanguage.includes('-');
}, []);
const getLocaleFromLanguage = useCallback((language: string) => {
if (Languages.ENGLISH === language) {
return Locales.ENGLISH_US;
}
return Locales.DEFAULT;
}, []);
const getLanguageFromLocale = useCallback((locale: string) => {
const language = locale.split('-')[0];
if (Languages.ENGLISH === language) {
return Languages.ENGLISH;
}
return Languages.DEFAULT;
}, []);
const setLocales = useCallback(() => {
let i18nLanguage = i18n.resolvedLanguage;
let locale: string;
let language: string;
if (!i18nLanguage) {
i18nLanguage = Languages.DEFAULT;
}
if (isLocale(i18nLanguage)) {
language = getLanguageFromLocale(i18nLanguage);
locale = getLocaleFromLanguage(language);
} else {
locale = getLocaleFromLanguage(i18nLanguage);
language = getLanguageFromLocale(locale);
}
if (Languages.ENGLISH === language) {
setAntLocale(en_US);
moment.updateLocale(language, null);
} else {
setAntLocale(en_US);
}
setLocale(locale);
setLanguage(language);
}, [getLanguageFromLocale, getLocaleFromLanguage, i18n.resolvedLanguage, isLocale]);
const renderProvider = useMemo(() => {
return (
<LocaleContext.Provider value={{ locale: locale, language: language }}>
<ConfigProvider locale={antLocale}>{children}</ConfigProvider>
</LocaleContext.Provider>
);
}, [antLocale, children, language, locale]);
useEffect(() => {
setLocales();
}, [setLocales, i18n]);
if (language && locale) {
return renderProvider;
}
return (
<Layout>
<Layout.Content>
<Row align="middle">
<Col span={24}>
<Spin tip={t('loading')} spinning />
</Col>
</Row>
</Layout.Content>
</Layout>
);
};
export const useLocaleProviderContext = (): LocaleProviderContextType => {
const context = useContext(LocaleContext);
if (context === undefined) {
throw new Error('useLocaleProviderContext must be used within LocaleProvider');
}
return context;
};
export default LocaleContextProvider;

View file

@ -1,6 +1,17 @@
import moment from 'moment-timezone';
import DateTimeStyle from '../constants/dateTimeStyle';
export const formatDateTimeWithTimeZone = (date: number | string, timezone = moment.tz.guess()) => {
const d = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'long', timeZone: timezone });
return d.format(new Date(date));
export const formatDateTimeWithTimeZone = (
date: number | string,
timeStyle: DateTimeStyle,
dateStyle: DateTimeStyle,
locale: string,
timezone = moment.tz.guess()
) => {
const dtf = new Intl.DateTimeFormat(locale, {
timeStyle: timeStyle,
dateStyle: dateStyle,
timeZone: timezone
});
return dtf.format(new Date(date));
};