@openmrs/esm-implementer-tools-app 11.0.1-pre.6014 → 11.0.1-pre.6015
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -4
- package/dist/1558.css +1 -0
- package/dist/{8088.css.map → 1558.css.map} +1 -1
- package/dist/1558.js +1 -0
- package/dist/1558.js.map +1 -0
- package/dist/1944.js +1 -0
- package/dist/1944.js.map +1 -0
- package/dist/{6479.css.map → 2989.css.map} +1 -1
- package/dist/2989.js +1 -0
- package/dist/2989.js.map +1 -0
- package/dist/4540.js +1 -0
- package/dist/4540.js.map +1 -0
- package/dist/{4698.js → 4938.js} +1 -1
- package/dist/4938.js.map +1 -0
- package/dist/5117.js +1 -1
- package/dist/5117.js.map +1 -1
- package/dist/5822.js +1 -0
- package/dist/5822.js.map +1 -0
- package/dist/6977.js +15 -0
- package/dist/6977.js.map +1 -0
- package/dist/7218.js +1 -0
- package/dist/7218.js.map +1 -0
- package/dist/8978.js +1 -0
- package/dist/8978.js.map +1 -0
- package/dist/932.js +1 -1
- package/dist/932.js.map +1 -1
- package/dist/openmrs-esm-implementer-tools-app.js +2 -2
- package/dist/openmrs-esm-implementer-tools-app.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +3 -3
- package/src/implementer-tools.test.tsx +38 -2
- package/src/popup/popup.component.tsx +3 -0
- package/src/popup/popup.test.tsx +2 -1
- package/src/store.ts +30 -1
- package/src/translations/translation-catalog.ts +14 -4
- package/src/translations/translations.component.tsx +696 -0
- package/src/translations/translations.styles.scss +133 -0
- package/src/translations/translations.test.tsx +839 -0
- package/translations/en.json +34 -0
- package/dist/1492.js +0 -1
- package/dist/1492.js.map +0 -1
- package/dist/1695.js +0 -1
- package/dist/1695.js.map +0 -1
- package/dist/195.js +0 -15
- package/dist/195.js.map +0 -1
- package/dist/2536.js +0 -1
- package/dist/2536.js.map +0 -1
- package/dist/4698.js.map +0 -1
- package/dist/6479.js +0 -1
- package/dist/6479.js.map +0 -1
- package/dist/8088.css +0 -1
- package/dist/8088.js +0 -1
- package/dist/8088.js.map +0 -1
- package/dist/9270.js +0 -1
- package/dist/9270.js.map +0 -1
- /package/dist/{6479.css → 2989.css} +0 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Column,
|
|
6
|
+
Dropdown,
|
|
7
|
+
FlexGrid,
|
|
8
|
+
InlineNotification,
|
|
9
|
+
Pagination,
|
|
10
|
+
Row,
|
|
11
|
+
Table,
|
|
12
|
+
TableBody,
|
|
13
|
+
TableCell,
|
|
14
|
+
TableContainer,
|
|
15
|
+
TableHead,
|
|
16
|
+
TableHeader,
|
|
17
|
+
TableRow,
|
|
18
|
+
TextInput,
|
|
19
|
+
Toggle,
|
|
20
|
+
} from '@carbon/react';
|
|
21
|
+
import { Copy, Download } from '@carbon/react/icons';
|
|
22
|
+
import { EditIcon, getCoreTranslation, ResetIcon } from '@openmrs/esm-framework';
|
|
23
|
+
import { temporaryConfigStore, useStore, useStoreWithActions } from '@openmrs/esm-framework/src/internal';
|
|
24
|
+
import { implementerToolsStore, type ImplementerToolsStore } from '../store';
|
|
25
|
+
import {
|
|
26
|
+
getMissingNamespaces,
|
|
27
|
+
loadTranslationLanguage,
|
|
28
|
+
loadTranslationNamespace,
|
|
29
|
+
temporaryConfigSource,
|
|
30
|
+
type TranslationEntry,
|
|
31
|
+
translationOverridesKey,
|
|
32
|
+
useTranslationEntries,
|
|
33
|
+
useTranslationLanguages,
|
|
34
|
+
useTranslationNamespaces,
|
|
35
|
+
} from './translation-catalog';
|
|
36
|
+
import { asError } from './i18next-loads';
|
|
37
|
+
import {
|
|
38
|
+
downloadJson,
|
|
39
|
+
getLocalEdits,
|
|
40
|
+
toFileSafeName,
|
|
41
|
+
toOverrideFile,
|
|
42
|
+
toTransifexLocale,
|
|
43
|
+
transifexSourceLanguage,
|
|
44
|
+
} from './translation-exports';
|
|
45
|
+
import { reloadTranslations, resetTranslationOverride, setTranslationOverride } from './translation-overrides';
|
|
46
|
+
import styles from './translations.styles.scss';
|
|
47
|
+
|
|
48
|
+
/** The sentinel used by the namespace filter to mean "every namespace i18next has loaded". */
|
|
49
|
+
const allNamespaces = '*';
|
|
50
|
+
|
|
51
|
+
const pageSizes = [25, 50, 100];
|
|
52
|
+
|
|
53
|
+
/** How long a row's copy button reads "Copied" after a copy. */
|
|
54
|
+
const copiedFeedbackMs = 2000;
|
|
55
|
+
|
|
56
|
+
const actions = {
|
|
57
|
+
toggleIsTranslationInspectorEnabled({ isTranslationInspectorEnabled }: ImplementerToolsStore) {
|
|
58
|
+
return { isTranslationInspectorEnabled: !isTranslationInspectorEnabled };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function Translations() {
|
|
63
|
+
const { t } = useTranslation();
|
|
64
|
+
const { isTranslationInspectorEnabled, selectedTranslation, toggleIsTranslationInspectorEnabled } =
|
|
65
|
+
useStoreWithActions(implementerToolsStore, actions);
|
|
66
|
+
|
|
67
|
+
const [language, setLanguage] = useState(() => window.i18next?.language ?? 'en');
|
|
68
|
+
const [selectedNamespace, setSelectedNamespace] = useState(allNamespaces);
|
|
69
|
+
const [filterText, setFilterText] = useState('');
|
|
70
|
+
// The exact key the inspector handed over, shown alone until the search text is edited.
|
|
71
|
+
const [focused, setFocused] = useState<{ key: string; value?: string } | null>(null);
|
|
72
|
+
const [onlyOverridden, setOnlyOverridden] = useState(false);
|
|
73
|
+
const [requestedPage, setPage] = useState(1);
|
|
74
|
+
const [pageSize, setPageSize] = useState(pageSizes[0]);
|
|
75
|
+
const [loadFailure, setLoadFailure] = useState<string | null>(null);
|
|
76
|
+
// Dismissing the notification hides it without forgetting the failure, which the empty state still
|
|
77
|
+
// needs so as not to claim the module has no translations.
|
|
78
|
+
const [isLoadFailureDismissed, setIsLoadFailureDismissed] = useState(false);
|
|
79
|
+
const [actionFailure, setActionFailure] = useState<string | null>(null);
|
|
80
|
+
// Counted rather than a flag: a language change can start a language load and a namespace load,
|
|
81
|
+
// and the first to finish must not declare the table empty while the other is still fetching.
|
|
82
|
+
const [loadCount, setLoadCount] = useState(0);
|
|
83
|
+
const namespaceRequest = useRef(0);
|
|
84
|
+
const languageRequest = useRef(0);
|
|
85
|
+
|
|
86
|
+
const languages = useTranslationLanguages(language);
|
|
87
|
+
const namespaces = useTranslationNamespaces(language);
|
|
88
|
+
const browsedNamespaces = useMemo(
|
|
89
|
+
() =>
|
|
90
|
+
selectedNamespace === allNamespaces
|
|
91
|
+
? namespaces.filter((namespace) => namespace.isLoaded).map((namespace) => namespace.name)
|
|
92
|
+
: [selectedNamespace],
|
|
93
|
+
[namespaces, selectedNamespace],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const entries = useTranslationEntries(browsedNamespaces, language);
|
|
97
|
+
|
|
98
|
+
const filteredEntries = useMemo(() => {
|
|
99
|
+
const needle = filterText.trim().toLowerCase();
|
|
100
|
+
|
|
101
|
+
return entries.filter((entry) => {
|
|
102
|
+
if (onlyOverridden && !entry.overrideSource) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (focused) {
|
|
107
|
+
return entry.key === focused.key;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return !needle || entry.key.toLowerCase().includes(needle) || entry.value.toLowerCase().includes(needle);
|
|
111
|
+
});
|
|
112
|
+
}, [entries, filterText, focused, onlyOverridden]);
|
|
113
|
+
|
|
114
|
+
// A key the inspector saw rendered that no bundle has, as when a module's translation file lags
|
|
115
|
+
// behind its code. The application shows its default text, which can still be overridden.
|
|
116
|
+
const missingEntry = useMemo<TranslationEntry | null>(
|
|
117
|
+
() =>
|
|
118
|
+
focused &&
|
|
119
|
+
filteredEntries.length === 0 &&
|
|
120
|
+
!onlyOverridden &&
|
|
121
|
+
selectedNamespace !== allNamespaces &&
|
|
122
|
+
loadCount === 0 &&
|
|
123
|
+
!loadFailure
|
|
124
|
+
? {
|
|
125
|
+
namespace: selectedNamespace,
|
|
126
|
+
key: focused.key,
|
|
127
|
+
value: focused.value ?? '',
|
|
128
|
+
sourceLanguage: language,
|
|
129
|
+
pluralForms: [],
|
|
130
|
+
}
|
|
131
|
+
: null,
|
|
132
|
+
[filteredEntries, focused, language, loadCount, loadFailure, onlyOverridden, selectedNamespace],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const visibleEntries = useMemo(
|
|
136
|
+
() => (missingEntry ? [missingEntry] : filteredEntries),
|
|
137
|
+
[filteredEntries, missingEntry],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
setPage(1);
|
|
142
|
+
}, [filterText, onlyOverridden, selectedNamespace, language]);
|
|
143
|
+
|
|
144
|
+
// The list also shrinks for reasons that don't reset the page, and Carbon's Pagination doesn't
|
|
145
|
+
// pull the page back into range, leaving a blank table with no empty state.
|
|
146
|
+
const page = Math.min(requestedPage, Math.max(1, Math.ceil(visibleEntries.length / pageSize)));
|
|
147
|
+
|
|
148
|
+
// Keep the stored page in step with the clamp, so Carbon and this component never disagree about
|
|
149
|
+
// which page is showing.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (requestedPage !== page) {
|
|
152
|
+
setPage(page);
|
|
153
|
+
}
|
|
154
|
+
}, [page, requestedPage]);
|
|
155
|
+
|
|
156
|
+
const pagedEntries = useMemo(
|
|
157
|
+
() => visibleEntries.slice((page - 1) * pageSize, page * pageSize),
|
|
158
|
+
[visibleEntries, page, pageSize],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Logging outside the staleness check keeps a superseded failure traceable even when it is not
|
|
162
|
+
// worth putting in front of the user.
|
|
163
|
+
const reportLoadFailure = useCallback((message: string, error: unknown, isCurrent: boolean) => {
|
|
164
|
+
console.error(message, error);
|
|
165
|
+
|
|
166
|
+
if (isCurrent) {
|
|
167
|
+
setLoadFailure(`${message} ${asError(error).message}`);
|
|
168
|
+
setIsLoadFailureDismissed(false);
|
|
169
|
+
}
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
const reportActionFailure = useCallback((message: string, error: unknown) => {
|
|
173
|
+
console.error(message, error);
|
|
174
|
+
setActionFailure(`${message} ${asError(error).message}`);
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const clearActionFailure = useCallback(() => setActionFailure(null), []);
|
|
178
|
+
|
|
179
|
+
const loadNamespace = useCallback(
|
|
180
|
+
(namespace: string, forLanguage: string) => {
|
|
181
|
+
// A slower earlier load must not report against, or clear the indicator of, the selection that
|
|
182
|
+
// replaced it.
|
|
183
|
+
const request = ++namespaceRequest.current;
|
|
184
|
+
|
|
185
|
+
setLoadCount((count) => count + 1);
|
|
186
|
+
loadTranslationNamespace(namespace, forLanguage)
|
|
187
|
+
.catch((error) =>
|
|
188
|
+
reportLoadFailure(
|
|
189
|
+
t('couldNotLoadNamespace', 'Could not load this module’s translations.'),
|
|
190
|
+
error,
|
|
191
|
+
namespaceRequest.current === request,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
.finally(() => setLoadCount((count) => count - 1));
|
|
195
|
+
},
|
|
196
|
+
[reportLoadFailure, t],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (selectedTranslation) {
|
|
201
|
+
// Like any other change of namespace, this supersedes a load still running for the previous one.
|
|
202
|
+
namespaceRequest.current += 1;
|
|
203
|
+
setLoadFailure(null);
|
|
204
|
+
setSelectedNamespace(selectedTranslation.namespace);
|
|
205
|
+
setFilterText(selectedTranslation.key);
|
|
206
|
+
setFocused({ key: selectedTranslation.key, value: selectedTranslation.value });
|
|
207
|
+
setOnlyOverridden(false);
|
|
208
|
+
implementerToolsStore.setState({ selectedTranslation: null });
|
|
209
|
+
|
|
210
|
+
if (!window.i18next.hasResourceBundle(language, selectedTranslation.namespace)) {
|
|
211
|
+
loadNamespace(selectedTranslation.namespace, language);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}, [language, loadNamespace, selectedTranslation]);
|
|
215
|
+
|
|
216
|
+
// A selection that repeats the current one must not clear an error or start a fetch.
|
|
217
|
+
const handleNamespaceChange = useCallback(
|
|
218
|
+
({ selectedItem }: { selectedItem: string | null }) => {
|
|
219
|
+
if (!selectedItem || selectedItem === selectedNamespace) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setSelectedNamespace(selectedItem);
|
|
224
|
+
setLoadFailure(null);
|
|
225
|
+
|
|
226
|
+
// Superseding any load already running, including when the new selection needs none: its
|
|
227
|
+
// failure would otherwise be reported against whatever is on screen by the time it lands.
|
|
228
|
+
namespaceRequest.current += 1;
|
|
229
|
+
|
|
230
|
+
const isLoaded = namespaces.find((namespace) => namespace.name === selectedItem)?.isLoaded;
|
|
231
|
+
|
|
232
|
+
if (selectedItem !== allNamespaces && !isLoaded) {
|
|
233
|
+
loadNamespace(selectedItem, language);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
[language, loadNamespace, namespaces, selectedNamespace],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const handleLanguageChange = useCallback(
|
|
240
|
+
({ selectedItem }: { selectedItem: string | null }) => {
|
|
241
|
+
if (!selectedItem || selectedItem === language) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
setLanguage(selectedItem);
|
|
246
|
+
setLoadFailure(null);
|
|
247
|
+
|
|
248
|
+
// Supersedes any load still running for the previous language, whose failure would otherwise
|
|
249
|
+
// be reported against this one.
|
|
250
|
+
namespaceRequest.current += 1;
|
|
251
|
+
const request = ++languageRequest.current;
|
|
252
|
+
|
|
253
|
+
if (getMissingNamespaces(selectedItem).length > 0) {
|
|
254
|
+
setLoadCount((count) => count + 1);
|
|
255
|
+
loadTranslationLanguage(selectedItem)
|
|
256
|
+
.catch((error) =>
|
|
257
|
+
reportLoadFailure(
|
|
258
|
+
t('couldNotLoadLanguage', 'Could not load translations for this language.'),
|
|
259
|
+
error,
|
|
260
|
+
languageRequest.current === request,
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
.finally(() => setLoadCount((count) => count - 1));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// A namespace loaded on demand was fetched for the language it was picked under, and
|
|
267
|
+
// `reloadResources` does not register it for later language loads, so it has to be asked for
|
|
268
|
+
// again rather than reported as having no translations.
|
|
269
|
+
if (selectedNamespace !== allNamespaces && !window.i18next.hasResourceBundle(selectedItem, selectedNamespace)) {
|
|
270
|
+
loadNamespace(selectedNamespace, selectedItem);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[language, loadNamespace, reportLoadFailure, selectedNamespace, t],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const headers = useMemo(
|
|
277
|
+
() => [
|
|
278
|
+
{ key: 'namespace', header: t('translationNamespace', 'Namespace') },
|
|
279
|
+
{ key: 'key', header: t('translationKey', 'Key') },
|
|
280
|
+
{ key: 'value', header: t('translationValue', 'Value') },
|
|
281
|
+
{ key: 'source', header: t('translationSource', 'Source') },
|
|
282
|
+
{ key: 'actions', header: '' },
|
|
283
|
+
],
|
|
284
|
+
[t],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div className={styles.translations}>
|
|
289
|
+
<div className={styles.tools}>
|
|
290
|
+
<FlexGrid className={styles.toolbar}>
|
|
291
|
+
<Row className={styles.row}>
|
|
292
|
+
<Column>
|
|
293
|
+
<TextInput
|
|
294
|
+
autoComplete="off"
|
|
295
|
+
id="translationSearch"
|
|
296
|
+
labelText={t('searchTranslations', 'Search translations by key or text')}
|
|
297
|
+
onChange={(event) => {
|
|
298
|
+
setFilterText(event.target.value);
|
|
299
|
+
setFocused(null);
|
|
300
|
+
}}
|
|
301
|
+
value={filterText}
|
|
302
|
+
/>
|
|
303
|
+
</Column>
|
|
304
|
+
<Column>
|
|
305
|
+
<Dropdown
|
|
306
|
+
id="translationNamespaceFilter"
|
|
307
|
+
items={[allNamespaces, ...namespaces.map((namespace) => namespace.name)]}
|
|
308
|
+
itemToString={(name: string) =>
|
|
309
|
+
name === allNamespaces
|
|
310
|
+
? t('allLoadedNamespaces', 'All loaded namespaces')
|
|
311
|
+
: namespaces.find((namespace) => namespace.name === name)?.isLoaded
|
|
312
|
+
? name
|
|
313
|
+
: t('namespaceNotLoaded', '{{module}} (not loaded)', {
|
|
314
|
+
module: name,
|
|
315
|
+
interpolation: { escapeValue: false },
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
label={t('allLoadedNamespaces', 'All loaded namespaces')}
|
|
319
|
+
onChange={handleNamespaceChange}
|
|
320
|
+
selectedItem={selectedNamespace}
|
|
321
|
+
titleText={t('translationNamespace', 'Namespace')}
|
|
322
|
+
/>
|
|
323
|
+
</Column>
|
|
324
|
+
<Column>
|
|
325
|
+
<Dropdown
|
|
326
|
+
id="translationLanguageFilter"
|
|
327
|
+
items={languages}
|
|
328
|
+
itemToString={(item: string) => item}
|
|
329
|
+
label={language}
|
|
330
|
+
onChange={handleLanguageChange}
|
|
331
|
+
selectedItem={language}
|
|
332
|
+
titleText={t('translationLanguage', 'Language')}
|
|
333
|
+
/>
|
|
334
|
+
</Column>
|
|
335
|
+
<Column className={styles.toggleButtons}>
|
|
336
|
+
<Toggle
|
|
337
|
+
className={styles.toggle}
|
|
338
|
+
id="onlyOverriddenSwitch"
|
|
339
|
+
labelText={t('onlyOverridden', 'Only overridden')}
|
|
340
|
+
onToggle={() => setOnlyOverridden((current) => !current)}
|
|
341
|
+
toggled={onlyOverridden}
|
|
342
|
+
/>
|
|
343
|
+
<Toggle
|
|
344
|
+
className={styles.toggle}
|
|
345
|
+
id="translationInspectorSwitch"
|
|
346
|
+
labelText={t('translationInspector', 'Translation inspector')}
|
|
347
|
+
onToggle={toggleIsTranslationInspectorEnabled}
|
|
348
|
+
toggled={isTranslationInspectorEnabled}
|
|
349
|
+
/>
|
|
350
|
+
</Column>
|
|
351
|
+
</Row>
|
|
352
|
+
<TranslationExports language={language} namespace={selectedNamespace} />
|
|
353
|
+
</FlexGrid>
|
|
354
|
+
{[isLoadFailureDismissed ? null : loadFailure, actionFailure].filter(Boolean).map((message) => (
|
|
355
|
+
<InlineNotification
|
|
356
|
+
className={styles.failure}
|
|
357
|
+
key={message}
|
|
358
|
+
kind="error"
|
|
359
|
+
lowContrast
|
|
360
|
+
onCloseButtonClick={() =>
|
|
361
|
+
message === loadFailure ? setIsLoadFailureDismissed(true) : setActionFailure(null)
|
|
362
|
+
}
|
|
363
|
+
subtitle={message ?? undefined}
|
|
364
|
+
title={t('translationsProblem', 'Something went wrong')}
|
|
365
|
+
/>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
<div className={styles.mainContent}>
|
|
369
|
+
<TableContainer title="">
|
|
370
|
+
<Table size="sm" useZebraStyles>
|
|
371
|
+
<TableHead>
|
|
372
|
+
<TableRow>
|
|
373
|
+
{headers.map((header) => (
|
|
374
|
+
<TableHeader key={header.key}>{header.header}</TableHeader>
|
|
375
|
+
))}
|
|
376
|
+
</TableRow>
|
|
377
|
+
</TableHead>
|
|
378
|
+
<TableBody>
|
|
379
|
+
{pagedEntries.map((entry) => (
|
|
380
|
+
// Keyed by language as well, so that an edit in progress is discarded rather than
|
|
381
|
+
// saved under whichever language is selected next.
|
|
382
|
+
<TranslationRow
|
|
383
|
+
entry={entry}
|
|
384
|
+
isMissing={entry === missingEntry}
|
|
385
|
+
key={`${language}|${entry.namespace}|${entry.key}`}
|
|
386
|
+
language={language}
|
|
387
|
+
onFailure={reportActionFailure}
|
|
388
|
+
onSuccess={clearActionFailure}
|
|
389
|
+
/>
|
|
390
|
+
))}
|
|
391
|
+
</TableBody>
|
|
392
|
+
</Table>
|
|
393
|
+
{visibleEntries.length === 0 ? (
|
|
394
|
+
<p className={styles.emptyState}>
|
|
395
|
+
{loadCount > 0
|
|
396
|
+
? t('loadingTranslations', 'Loading translations…')
|
|
397
|
+
: // An empty table after a failed load says nothing about whether the module has
|
|
398
|
+
// translations.
|
|
399
|
+
loadFailure
|
|
400
|
+
? t('translationsCouldNotBeLoaded', 'These translations could not be loaded.')
|
|
401
|
+
: browsedNamespaces.length === 0
|
|
402
|
+
? t(
|
|
403
|
+
'noNamespacesLoaded',
|
|
404
|
+
'No translations have been loaded yet. Pick a namespace above to load one.',
|
|
405
|
+
)
|
|
406
|
+
: entries.length === 0
|
|
407
|
+
? t('namespaceHasNoTranslations', 'This module has no translations for this language.')
|
|
408
|
+
: t('noMatchingTranslations', 'No translations match your search.')}
|
|
409
|
+
</p>
|
|
410
|
+
) : null}
|
|
411
|
+
</TableContainer>
|
|
412
|
+
</div>
|
|
413
|
+
<Pagination
|
|
414
|
+
className={styles.pagination}
|
|
415
|
+
// Carbon has been observed reporting the current page back outside a click, so a change that
|
|
416
|
+
// isn't one is dropped rather than turned into a pointless render.
|
|
417
|
+
onChange={({ page: nextPage, pageSize: nextPageSize }: { page: number; pageSize: number }) => {
|
|
418
|
+
setPage((current) => (current === nextPage ? current : nextPage));
|
|
419
|
+
setPageSize((current) => (current === nextPageSize ? current : nextPageSize));
|
|
420
|
+
}}
|
|
421
|
+
page={page}
|
|
422
|
+
pageSize={pageSize}
|
|
423
|
+
pageSizes={pageSizes}
|
|
424
|
+
size="sm"
|
|
425
|
+
totalItems={visibleEntries.length}
|
|
426
|
+
/>
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
interface TranslationRowProps {
|
|
432
|
+
entry: TranslationEntry;
|
|
433
|
+
/** Whether no translation file has the key, so that its value is only what the inspector saw. */
|
|
434
|
+
isMissing?: boolean;
|
|
435
|
+
language: string;
|
|
436
|
+
onFailure(message: string, error: unknown): void;
|
|
437
|
+
onSuccess(): void;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function TranslationRow({ entry, isMissing, language, onFailure, onSuccess }: TranslationRowProps) {
|
|
441
|
+
const { t } = useTranslation();
|
|
442
|
+
const [editing, setEditing] = useState(false);
|
|
443
|
+
const [draft, setDraft] = useState(entry.value);
|
|
444
|
+
const [copied, setCopied] = useState(false);
|
|
445
|
+
const editButtonRef = useRef<HTMLButtonElement>(null);
|
|
446
|
+
const wasEditing = useRef(false);
|
|
447
|
+
|
|
448
|
+
// The editor replaces the Edit button, so without this, closing it would leave focus nowhere.
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
if (wasEditing.current && !editing) {
|
|
451
|
+
editButtonRef.current?.focus();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
wasEditing.current = editing;
|
|
455
|
+
}, [editing]);
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
if (!copied) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const timer = setTimeout(() => setCopied(false), copiedFeedbackMs);
|
|
463
|
+
return () => clearTimeout(timer);
|
|
464
|
+
}, [copied]);
|
|
465
|
+
|
|
466
|
+
const save = useCallback(async () => {
|
|
467
|
+
setTranslationOverride(entry.namespace, language, entry.key, draft);
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
await reloadTranslations(entry.namespace, language);
|
|
471
|
+
setEditing(false);
|
|
472
|
+
onSuccess();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
// The override did reach the temporary config, so the editor stays open holding the value
|
|
475
|
+
// that was written; closing it would leave that value visible nowhere.
|
|
476
|
+
onFailure(
|
|
477
|
+
t('overrideSavedNotApplied', 'The override was saved but could not be applied. Reload the page to see it.'),
|
|
478
|
+
error,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}, [draft, entry.key, entry.namespace, language, onFailure, onSuccess, t]);
|
|
482
|
+
|
|
483
|
+
const reset = useCallback(async () => {
|
|
484
|
+
resetTranslationOverride(entry.namespace, language, entry.key);
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
await reloadTranslations(entry.namespace, language, entry.key);
|
|
488
|
+
setEditing(false);
|
|
489
|
+
onSuccess();
|
|
490
|
+
} catch (error) {
|
|
491
|
+
onFailure(
|
|
492
|
+
t('overrideResetNotApplied', 'The override was removed but could not be applied. Reload the page to see it.'),
|
|
493
|
+
error,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}, [entry.key, entry.namespace, language, onFailure, onSuccess, t]);
|
|
497
|
+
|
|
498
|
+
const cancel = useCallback(() => {
|
|
499
|
+
setDraft(entry.value);
|
|
500
|
+
setEditing(false);
|
|
501
|
+
}, [entry.value]);
|
|
502
|
+
|
|
503
|
+
const handleEditorKeyDown = useCallback(
|
|
504
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
505
|
+
if (event.key === 'Enter') {
|
|
506
|
+
event.preventDefault();
|
|
507
|
+
save();
|
|
508
|
+
} else if (event.key === 'Escape') {
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
cancel();
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
[cancel, save],
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
<TableRow>
|
|
518
|
+
<TableCell className={styles.namespaceCell}>{entry.namespace}</TableCell>
|
|
519
|
+
<TableCell>
|
|
520
|
+
<span className={styles.key}>{entry.key}</span>
|
|
521
|
+
{entry.pluralForms.length > 0 ? (
|
|
522
|
+
<p className={styles.hint}>
|
|
523
|
+
{t('alsoOverridePluralForms', 'Plural forms to override too: {{forms}}', {
|
|
524
|
+
forms: entry.pluralForms.join(', '),
|
|
525
|
+
interpolation: { escapeValue: false },
|
|
526
|
+
})}
|
|
527
|
+
</p>
|
|
528
|
+
) : null}
|
|
529
|
+
</TableCell>
|
|
530
|
+
<TableCell>
|
|
531
|
+
{editing ? (
|
|
532
|
+
<div className={styles.editor}>
|
|
533
|
+
<TextInput
|
|
534
|
+
autoFocus
|
|
535
|
+
hideLabel
|
|
536
|
+
id={`translationValue-${entry.namespace}-${entry.key}`}
|
|
537
|
+
labelText={t('translationValue', 'Value')}
|
|
538
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
539
|
+
onKeyDown={handleEditorKeyDown}
|
|
540
|
+
size="sm"
|
|
541
|
+
value={draft}
|
|
542
|
+
/>
|
|
543
|
+
<Button kind="ghost" onClick={save} size="sm">
|
|
544
|
+
{getCoreTranslation('save')}
|
|
545
|
+
</Button>
|
|
546
|
+
<Button kind="ghost" onClick={cancel} size="sm">
|
|
547
|
+
{getCoreTranslation('cancel')}
|
|
548
|
+
</Button>
|
|
549
|
+
</div>
|
|
550
|
+
) : (
|
|
551
|
+
<span className={styles.value}>{entry.value}</span>
|
|
552
|
+
)}
|
|
553
|
+
</TableCell>
|
|
554
|
+
<TableCell className={styles.sourceCell}>
|
|
555
|
+
{entry.overrideSource ??
|
|
556
|
+
(isMissing
|
|
557
|
+
? t('notInTranslationFile', 'Not in the translation file')
|
|
558
|
+
: t('translationFile', 'Translation file'))}
|
|
559
|
+
{entry.sourceLanguage === language ? null : (
|
|
560
|
+
<p className={styles.hint}>
|
|
561
|
+
{t('valueFromFallbackLanguage', 'Value shown is {{language}}’s', {
|
|
562
|
+
language: entry.sourceLanguage,
|
|
563
|
+
interpolation: { escapeValue: false },
|
|
564
|
+
})}
|
|
565
|
+
</p>
|
|
566
|
+
)}
|
|
567
|
+
</TableCell>
|
|
568
|
+
<TableCell>
|
|
569
|
+
{/* The actions sit against the table's right edge, where a centred tooltip is cut off. */}
|
|
570
|
+
<div className={styles.rowActions}>
|
|
571
|
+
{editing ? null : (
|
|
572
|
+
<Button
|
|
573
|
+
hasIconOnly
|
|
574
|
+
iconDescription={t('editValueButtonText', 'Edit')}
|
|
575
|
+
kind="ghost"
|
|
576
|
+
onClick={() => {
|
|
577
|
+
setDraft(entry.value);
|
|
578
|
+
setCopied(false);
|
|
579
|
+
setEditing(true);
|
|
580
|
+
}}
|
|
581
|
+
ref={editButtonRef}
|
|
582
|
+
renderIcon={(props) => <EditIcon size={16} {...props} />}
|
|
583
|
+
size="sm"
|
|
584
|
+
tooltipPosition="left"
|
|
585
|
+
/>
|
|
586
|
+
)}
|
|
587
|
+
{entry.overrideSource === temporaryConfigSource && entry.sourceLanguage === language ? (
|
|
588
|
+
<Button
|
|
589
|
+
hasIconOnly
|
|
590
|
+
iconDescription={t('resetToDefaultValueButtonText', 'Reset to default')}
|
|
591
|
+
kind="ghost"
|
|
592
|
+
onClick={reset}
|
|
593
|
+
renderIcon={(props) => <ResetIcon size={16} {...props} />}
|
|
594
|
+
size="sm"
|
|
595
|
+
tooltipPosition="left"
|
|
596
|
+
/>
|
|
597
|
+
) : null}
|
|
598
|
+
{/* Not Carbon's CopyButton: it animates "Copied" from the click alone, even on a rejected write. */}
|
|
599
|
+
{editing ? null : (
|
|
600
|
+
<Button
|
|
601
|
+
disabled={!navigator.clipboard}
|
|
602
|
+
hasIconOnly
|
|
603
|
+
iconDescription={
|
|
604
|
+
navigator.clipboard
|
|
605
|
+
? copied
|
|
606
|
+
? t('copied', 'Copied')
|
|
607
|
+
: t('copyOverrideJson', 'Copy override JSON')
|
|
608
|
+
: t('copyNeedsHttps', 'Copying needs the page to be served over HTTPS')
|
|
609
|
+
}
|
|
610
|
+
kind="ghost"
|
|
611
|
+
onClick={() =>
|
|
612
|
+
copyOverrideJson(entry, language).then(
|
|
613
|
+
() => setCopied(true),
|
|
614
|
+
(error) =>
|
|
615
|
+
onFailure(t('couldNotCopyOverride', 'Could not copy the override to the clipboard.'), error),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
renderIcon={(props) => <Copy size={16} {...props} />}
|
|
619
|
+
size="sm"
|
|
620
|
+
tooltipPosition="left"
|
|
621
|
+
/>
|
|
622
|
+
)}
|
|
623
|
+
</div>
|
|
624
|
+
</TableCell>
|
|
625
|
+
</TableRow>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Downloads the local edits (the temporary config's overrides) for one namespace and language, either
|
|
631
|
+
* as a config file for a distribution or as a translation file to upload to Transifex.
|
|
632
|
+
*/
|
|
633
|
+
function TranslationExports({ language, namespace }: { language: string; namespace: string }) {
|
|
634
|
+
const { t } = useTranslation();
|
|
635
|
+
const { config } = useStore(temporaryConfigStore);
|
|
636
|
+
const edits = namespace === allNamespaces ? {} : getLocalEdits(config, namespace, language);
|
|
637
|
+
const hasEdits = Object.keys(edits).length > 0;
|
|
638
|
+
const isSourceLanguage = language === transifexSourceLanguage;
|
|
639
|
+
|
|
640
|
+
const hint =
|
|
641
|
+
namespace === allNamespaces
|
|
642
|
+
? t('pickNamespaceToExport', 'Pick a namespace to export the changes made to it.')
|
|
643
|
+
: !hasEdits
|
|
644
|
+
? t('noChangesToExport', 'No changes have been made to this namespace in this language.')
|
|
645
|
+
: isSourceLanguage
|
|
646
|
+
? t(
|
|
647
|
+
'sourceLanguageNotExportable',
|
|
648
|
+
'English is the source language in Transifex; change it in the app’s en.json instead.',
|
|
649
|
+
)
|
|
650
|
+
: null;
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<div className={styles.exports}>
|
|
654
|
+
<Button
|
|
655
|
+
disabled={!hasEdits}
|
|
656
|
+
kind="tertiary"
|
|
657
|
+
onClick={() =>
|
|
658
|
+
downloadJson(
|
|
659
|
+
`${toFileSafeName(namespace)}.${language}.overrides.json`,
|
|
660
|
+
toOverrideFile(namespace, language, edits),
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
renderIcon={(props) => <Download size={16} {...props} />}
|
|
664
|
+
size="sm"
|
|
665
|
+
>
|
|
666
|
+
{t('downloadOverrides', 'Download overrides')}
|
|
667
|
+
</Button>
|
|
668
|
+
<Button
|
|
669
|
+
disabled={!hasEdits || isSourceLanguage}
|
|
670
|
+
kind="tertiary"
|
|
671
|
+
onClick={() => downloadJson(`${toTransifexLocale(language)}.json`, edits)}
|
|
672
|
+
renderIcon={(props) => <Download size={16} {...props} />}
|
|
673
|
+
size="sm"
|
|
674
|
+
>
|
|
675
|
+
{t('downloadForTransifex', 'Download for Transifex')}
|
|
676
|
+
</Button>
|
|
677
|
+
{hint ? <p className={styles.exportHint}>{hint}</p> : null}
|
|
678
|
+
</div>
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Puts the config an implementer would paste into their own configuration on the clipboard, so that
|
|
684
|
+
* a value tried out here can be made permanent.
|
|
685
|
+
*/
|
|
686
|
+
function copyOverrideJson(entry: TranslationEntry, language: string): Promise<void> {
|
|
687
|
+
const snippet = {
|
|
688
|
+
[entry.namespace]: {
|
|
689
|
+
[translationOverridesKey]: {
|
|
690
|
+
[language]: { [entry.key]: entry.value },
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
return navigator.clipboard.writeText(JSON.stringify(snippet, undefined, 2));
|
|
696
|
+
}
|