Skip to content

Commit 874db98

Browse files
committed
Merge branch 'main' of github.com:devforth/adminforth
2 parents d8aa025 + fd0fdaf commit 874db98

File tree

11 files changed

+533
-117
lines changed

11 files changed

+533
-117
lines changed

Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
# Added
1111

1212
- Command to generate typescript models `npx -y adminforth generate-models --env-file=.env`
13+
- add i18n support: add vue-i18n to frontend and tr function to backend. This will allow to implement translation plugins
1314

1415
# Improved
1516

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
# Internationalization (i18n)
2+
3+
This plugin allows you to translate your AdminForth application to multiple languages.
4+
Main features:
5+
- Stores all translation strings in your application in a single resource. Basically you can set allowed actions to Developers/Translators role only if you don't want other users to see the translations.
6+
- Supports AI completion adapters to help with translations. For example, you can use OpenAI ChatGPT to generate translations.
7+
- Supports any number of languages.
8+
9+
10+
Under the hood it uses vue-i18n library and provides several additional facilities to make the translation process easier.
11+
12+
13+
## Installation
14+
15+
To install the plugin:
16+
17+
```bash
18+
npm install @adminforth/i18n
19+
```
20+
21+
For example lets add translations to next 4 languages: Ukrainian, Japanese, French, Spanish. Also we will support basic translation for English.
22+
23+
24+
Add a model for translations, if you are using prisma, add something like this:
25+
26+
```ts title='./schema.prisma'
27+
model translations {
28+
id String @id
29+
en_string String
30+
created_at DateTime
31+
uk_string String? // translation for Ukrainian language
32+
ja_string String? // translation for Japanese language
33+
fr_string String? // translation for French language
34+
es_string String? // translation for Spanish language
35+
category String
36+
source String?
37+
completedLangs String?
38+
39+
// we need both indexes on en_string+category and separately on category
40+
@@index([en_string, category])
41+
@@index([category])
42+
}
43+
```
44+
45+
If you want more languages, just add more fields like `uk_string`, `ja_string`, `fr_string`, `es_string` to the model.
46+
47+
Next, add resource for translations:
48+
49+
```ts title='./resources/translations.ts'
50+
51+
import AdminForth, { AdminForthDataTypes, AdminForthResourceInput } from "adminforth";
52+
import CompletionAdapterOpenAIChatGPT from "@adminforth/completion-adapter-open-ai-chat-gpt";
53+
import I18nPlugin from "@adminforth/i18n";
54+
import { v1 as uuid } from "uuid";
55+
56+
57+
export default {
58+
dataSource: "maindb",
59+
table: "translations",
60+
resourceId: "translations",
61+
label: "Translations",
62+
63+
recordLabel: (r: any) => `✍️ ${r.en_string}`,
64+
plugins: [
65+
new I18nPlugin({
66+
supportedLanguages: ['en', 'uk', 'ja', 'fr'],
67+
68+
// names of the fields in the resource which will store translations
69+
translationFieldNames: {
70+
en: 'en_string',
71+
uk: 'uk_string',
72+
ja: 'ja_string',
73+
fr: 'fr_string',
74+
},
75+
76+
// name of the field which will store the category of the string
77+
// this helps to categorize strings and deliver them efficiently
78+
categoryFieldName: 'category',
79+
80+
// optional field to store the source (e.g. source file name)
81+
sourceFieldName: 'source',
82+
83+
// optional field store list of completed translations
84+
// will hel to filter out incomplete translations
85+
completedFieldName: 'completedLangs',
86+
87+
completeAdapter: new CompletionAdapterOpenAIChatGPT({
88+
openAiApiKey: process.env.OPENAI_API_KEY as string,
89+
}),
90+
}),
91+
92+
],
93+
options: {
94+
listPageSize: 30,
95+
},
96+
columns: [
97+
{
98+
name: "id",
99+
fillOnCreate: ({ initialRecord, adminUser }: any) => uuid(),
100+
primaryKey: true,
101+
showIn: [],
102+
},
103+
{
104+
name: "en_string",
105+
type: AdminForthDataTypes.STRING,
106+
},
107+
{
108+
name: "created_at",
109+
fillOnCreate: ({ initialRecord, adminUser }: any) => new Date().toISOString(),
110+
},
111+
{
112+
name: "uk_string",
113+
type: AdminForthDataTypes.STRING,
114+
},
115+
{
116+
name: "ja_string",
117+
type: AdminForthDataTypes.STRING,
118+
},
119+
{
120+
name: "fr_string",
121+
type: AdminForthDataTypes.STRING,
122+
},
123+
{
124+
name: "completedLangs",
125+
},
126+
{
127+
name: "source",
128+
showIn: ['filter', 'show'],
129+
type: AdminForthDataTypes.STRING,
130+
},
131+
{
132+
name: "category",
133+
showIn: ['filter', 'show', 'list'],
134+
type: AdminForthDataTypes.STRING,
135+
}
136+
],
137+
} as AdminForthResourceInput;
138+
```
139+
140+
Also add the resource to main file and add menu item in `./index.ts`:
141+
142+
```ts title='./index.ts'
143+
144+
import translations from "./resources/translations";
145+
...
146+
147+
const adminForth = new AdminForth({
148+
...
149+
resources: [
150+
...
151+
//diff-add
152+
translations,
153+
],
154+
menu: [
155+
...
156+
//diff-add
157+
{
158+
//diff-add
159+
label: 'Translations',
160+
//diff-add
161+
icon: 'material-symbols:translate',
162+
//diff-add
163+
resourceId: 'translations',
164+
//diff-add
165+
},
166+
],
167+
...
168+
});
169+
170+
```
171+
172+
This is it, now you should start your app and see the translations resource in the menu.
173+
174+
You can add translations for each language manually or use Bulk actions to generate translations with AI completion adapter.
175+
176+
For simplicity you can also use filter to get only untranslated strings and complete them one by one (filter name "Fully translated" in the filter).
177+
178+
179+
## Translation for custom components
180+
181+
To translate custom components, you should simply wrap all strings in $t function. For example:
182+
183+
Now create file `CustomLoginFooter.vue` in the `custom` folder of your project:
184+
185+
```html title="./custom/CustomLoginFooter.vue"
186+
<template>
187+
<div class="text-center text-gray-500 text-sm mt-4">
188+
//diff-remove
189+
By logging in, you agree to our <a href="#" class="text-blue-500">Terms of Service</a> and <a href="#" class="text-blue-500">Privacy Policy</a>
190+
//diff-add
191+
{{$t('By logging in, you agree to our')}} <a href="#" class="text-blue-500">{{$t('Terms of Service')}}</a> {{$t('and')}} <a href="#" class="text-blue-500">{{$t('Privacy Policy')}}</a>
192+
</div>
193+
</template>
194+
```
195+
196+
### Variables in frontend translations
197+
198+
You can use variables in translations in same way like you would do it with vue-i18n library.
199+
200+
This is generally helps to understand the context of the translation for AI completion adapters and simplifies the translation process, even if done manually.
201+
202+
For example if you have string "Showing 1 to 10 of 100 entries" you can of course simply do
203+
204+
```html
205+
{{$t('Showing')} {{from}} {{$t('to')}} {{to}} {{$t('of')}} {{total}} {{$t('entries')}}
206+
```
207+
208+
And it will form 4 translation strings. But it is much better to have it as single string with variables like this:
209+
210+
```html
211+
{{$t('Showing {from} to {to} of {total} entries', { from, to, total })}
212+
```
213+
214+
215+
For example, let's add user greeting to the header.
216+
217+
```html title="./custom/Header.vue"
218+
<template>
219+
<div class="flex items-center justify-between p-4 bg-white shadow-md">
220+
<div class="text-lg font-semibold text-gray-800">
221+
{{ $t('Welcome, {name}', { name: adminUser.username }) }}
222+
</div>
223+
</div>
224+
</template>
225+
226+
<script setup lang="ts">
227+
import type { AdminForthResourceColumnCommon, AdminForthResourceCommon, AdminUser } from '@/types/Common';
228+
229+
const props = defineProps<{
230+
column: AdminForthResourceColumnCommon;
231+
record: any;
232+
meta: any;
233+
resource: AdminForthResourceCommon;
234+
adminUser: AdminUser
235+
}>();
236+
</script>
237+
```
238+
239+
How to use such component
240+
241+
```typescript title="./index.ts"
242+
243+
const adminForth = new AdminForth({
244+
...
245+
customization{
246+
globalInjections: {
247+
header: {
248+
file: '@@/Header.vue',
249+
},
250+
}
251+
},
252+
...
253+
});
254+
255+
```
256+
257+
## Translations in custom APIs
258+
259+
Sometimes you need to return a translated error or success message from your API. You can use special `tr` function for this.
260+
261+
For simple example let's move previous example to format string on the backend side:
262+
263+
```html title="./custom/Header.vue"
264+
<template>
265+
<div class="flex items center justify-between p-4 bg-white shadow-md">
266+
<div class="text-lg font-semibold text-gray-800">
267+
{{ greeting }}
268+
</div>
269+
</div>
270+
</template>
271+
272+
<script setup lang="ts">
273+
import type { AdminForthResourceColumnCommon, AdminForthResourceCommon, AdminUser } from '@/types/Common';
274+
import { callApi } from '@/utils';
275+
import { ref, onMounted } from 'vue';
276+
277+
const greeting: Ref<string> = ref('');
278+
279+
onMounted(async () => {
280+
try {
281+
const data = await callApi({path: '/api/greeting', method: 'GET'});
282+
greeting.value = data.text;
283+
} catch (error) {
284+
window.adminforth.alert({
285+
message: `Error fetching data: ${error.message}`,
286+
variant: 'danger',
287+
timeout: 'unlimited'
288+
});
289+
return;
290+
}
291+
})
292+
</script>
293+
```
294+
295+
And on the backend side you can use tr function to translate the string:
296+
297+
```ts title="./index.ts"
298+
app.get(`${ADMIN_BASE_URL}/api/greeting`,
299+
admin.express.authorize(
300+
admin.express.translatable(
301+
async (req, res) => {
302+
res.json({
303+
text: await tr('Welcome, {name}', 'customApis', { name: req.adminUser.username }),
304+
});
305+
}
306+
)
307+
)
308+
);
309+
310+
// serve after you added all api
311+
admin.discoverDatabases();
312+
admin.express.serve(app)
313+
```
314+
315+
As you can see we should use `admin.express.translatable` middleware which will inject `tr` function to the request object.
316+
First param is the string to translate, second is the category of the string (actually you can use any string here), and the third is the variables object.
317+
318+
If you don't use params, you can use `tr` without third param:
319+
320+
```typescript
321+
{
322+
text: await tr('Welcome, dear user', 'customApis'),
323+
}
324+
```
325+
326+
> 🙅‍♂️ Temporary limitation: For now all translations strings for backend (adminforth internal and for from custom APIs)
327+
appear in Translations resource and table only after they are used. So greeting string will appear in the Translations table only after the first request to the API which reaches the `tr` function call.
328+
> So to collect all translations you should use your app for some time and make sure all strings are used at
329+
> In future we plan to add backend strings collection in same way like frontend strings are collected.
330+
331+
# Translating messaged within bulk action
332+
333+
Label adn confirm strings of bulk actions are already translated by AdminForth, but
334+
`succesMessage` returned by action function should be translated manually because of the dynamic nature of the message.
335+
336+
Let's rework the bulk action from [bulkActions](/docs/tutorial/Customization/bulkActions/) to use translations:
337+
338+
```ts title="./resources/apartments.ts"
339+
import { AdminUser } from 'adminforth';
340+
import { admin } from '../index';
341+
342+
{
343+
...
344+
resourceId: 'aparts',
345+
...
346+
options: {
347+
bulkActions: [
348+
{
349+
label: 'Mark as listed',
350+
icon: 'flowbite:eye-solid',
351+
// if optional `confirm` is provided, user will be asked to confirm action
352+
confirm: 'Are you sure you want to mark all selected apartments as listed?',
353+
action: function ({selectedIds, adminUser }: {selectedIds: any[], adminUser: AdminUser }) {
354+
const stmt = admin.resource('aparts').dataConnector.db.prepare(`UPDATE apartments SET listed = 1 WHERE id IN (${selectedIds.map(() => '?').join(',')})`);
355+
stmt.run(...selectedIds);
356+
//diff-remove
357+
return { ok: true, error: false, successMessage: `Marked ${selectedIds.length} apartments as listed` };
358+
//diff-add
359+
return { ok: true, error: false, successMessage: await tr('Marked {count} apartments as listed', 'apartments', { count: selectedIds.length }) };
360+
},
361+
}
362+
],
363+
}
364+
}
365+
```

adminforth/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,13 @@ class AdminForth implements IAdminForth {
116116
console.log(`🚀 AdminForth v${ADMINFORTH_VERSION} starting up`)
117117
}
118118

119-
async tr(this, msg: string, category: string = 'default', lang: string = 'en'): Promise<string> {
119+
async tr(this, msg: string, category: string = 'default', lang: string = 'en', params: any): Promise<string> {
120120
// stub function to make a translation
121+
if (params) {
122+
for (const key in params) {
123+
msg = msg.replace(new RegExp(`{${key}}`, 'g'), params[key]);
124+
}
125+
}
121126
return msg;
122127
}
123128

0 commit comments

Comments
 (0)