Skip to content

Commit 9461618

Browse files
committed
Merge branch 'next' of https://github.com/devforth/adminforth into next
2 parents 2126f66 + 5d3aa30 commit 9461618

File tree

3 files changed

+199
-20
lines changed

3 files changed

+199
-20
lines changed

adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ Now create file `CheckReadingTime.vue` in the `custom` folder of your project:
277277
```html title="./custom/CheckReadingTime.vue"
278278
<template>
279279
<div class="text-gray-500 text-sm">
280-
<div @click="checkReadingTime" class="cursor-pointer flex gap-2 items-center">
280+
<div class="cursor-pointer flex gap-2 items-center">
281281
Check reading time
282282
</div>
283283
</div>
@@ -287,6 +287,10 @@ Now create file `CheckReadingTime.vue` in the `custom` folder of your project:
287287
import { getReadingTime} from "text-analyzer";
288288
import adminforth from '@/adminforth';
289289
290+
defineExpose({
291+
click,
292+
});
293+
290294
function checkReadingTime() {
291295
const text = document.querySelector('[data-af-column="description"]')?.innerText;
292296
if (text) {
@@ -298,6 +302,11 @@ function checkReadingTime() {
298302
}
299303
adminforth.list.closeThreeDotsDropdown();
300304
}
305+
306+
function click() {
307+
checkReadingTime();
308+
}
309+
301310
</script>
302311
```
303312
@@ -312,6 +321,7 @@ npm i text-analyzer
312321
313322
> ☝️ Please note that we are using AdminForth [Frontend API](/docs/api/FrontendAPI/interfaces/FrontendAPIInterface/) `adminforth.list.closeThreeDotsDropdown();` to close the dropdown after the item is clicked.
314323
324+
>☝️ Please note that the injected component might have an exposed click function as well as a defined click function, which executes the click on component logic.
315325
316326
## List table custom action icons
317327

adminforth/documentation/docs/tutorial/07-Plugins/10-i18n.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ Main features:
55
- Stores all translation strings in your application in a single AdminForth resource. You can set [allowed actions](/docs/tutorial/Customization/limitingAccess/) only to Developers/Translators role if you don't want other users to see/edit the translations.
66
- Supports AI completion adapters to help with translations. For example, you can use OpenAI ChatGPT to generate translations. Supports correct pluralization, even for Slavic languages.
77
- Supports any number of languages.
8+
- Supports BCP47 language codes (e.g., `en-GB`, `pt-BR`) for regional language variants.
9+
- Configurable primary language.
810

911

1012
Under the hood it uses vue-i18n library and provides several additional facilities to make the translation process easier.
@@ -46,6 +48,8 @@ model translations {
4648
4749
If you want more languages, just add more fields like `uk_string`, `ja_string`, `fr_string`, `es_string` to the model.
4850
51+
> 💡 **Tip**: For regional language variants, you can also use BCP47 codes like `en-GB`, `pt-BR`, `fr-CA` and add corresponding fields like `enGB_string`, `ptBR_string`, `frCA_string`. See the [BCP47 Language Code Support](#bcp47-language-code-support) section for more details.
52+
4953
Next, add resource for translations:
5054
5155
```ts title='./resources/translations.ts'
@@ -86,6 +90,9 @@ export default {
8690
// will hel to filter out incomplete translations
8791
completedFieldName: 'completedLangs',
8892

93+
// optional: set primary language (defaults to 'en' if not specified)
94+
// primaryLanguage: 'fr', // Uncomment to set French as primary language
95+
8996
completeAdapter: new CompletionAdapterOpenAIChatGPT({
9097
openAiApiKey: process.env.OPENAI_API_KEY as string,
9198
model: 'gpt-4o-mini',
@@ -205,6 +212,125 @@ You can add translations for each language manually or use Bulk actions to gener
205212
206213
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).
207214
215+
## BCP47 Language Code Support
216+
217+
The i18n plugin supports BCP47 language tags, which allow you to specify regional variants of languages. This is particularly useful for applications that need to support different regional dialects or cultural variations.
218+
219+
### Supported Language Code Formats
220+
221+
- **ISO 639-1 codes**: Basic language codes like `en`, `fr`, `de`, `es`
222+
- **BCP47 tags**: Regional variants like `en-GB` (British English), `pt-BR` (Brazilian Portuguese), `en-US` (American English)
223+
224+
### Example with Regional Variants
225+
226+
Here's how to configure the plugin to support both basic and regional language codes:
227+
228+
```ts title='./resources/translations.ts'
229+
export default {
230+
dataSource: "maindb",
231+
table: "translations",
232+
resourceId: "translations",
233+
label: "Translations",
234+
235+
recordLabel: (r: any) => `✍️ ${r.en_string}`,
236+
plugins: [
237+
new I18nPlugin({
238+
// Support both basic and regional language codes
239+
supportedLanguages: ['en', 'en-GB', 'en-US', 'pt', 'pt-BR', 'fr', 'fr-CA'],
240+
241+
// Map language codes to database field names
242+
translationFieldNames: {
243+
en: 'en_string',
244+
'en-GB': 'enGB_string',
245+
'en-US': 'enUS_string',
246+
pt: 'pt_string',
247+
'pt-BR': 'ptBR_string',
248+
fr: 'fr_string',
249+
'fr-CA': 'frCA_string',
250+
},
251+
252+
categoryFieldName: 'category',
253+
sourceFieldName: 'source',
254+
completedFieldName: 'completedLangs',
255+
256+
completeAdapter: new CompletionAdapterOpenAIChatGPT({
257+
openAiApiKey: process.env.OPENAI_API_KEY as string,
258+
model: 'gpt-4o-mini',
259+
expert: {
260+
temperature: 0.5,
261+
},
262+
}),
263+
}),
264+
],
265+
// ... rest of configuration
266+
} as AdminForthResourceInput;
267+
```
268+
269+
### Database Schema for Regional Variants
270+
271+
When using BCP47 codes, make sure your database schema includes fields for each regional variant:
272+
273+
```ts title='./schema.prisma'
274+
model translations {
275+
id String @id
276+
en_string String
277+
created_at DateTime
278+
279+
// Basic language codes
280+
fr_string String?
281+
pt_string String?
282+
283+
// Regional variants (BCP47)
284+
enGB_string String? // British English
285+
enUS_string String? // American English
286+
ptBR_string String? // Brazilian Portuguese
287+
frCA_string String? // Canadian French
288+
289+
category String
290+
source String?
291+
completedLangs String?
292+
293+
@@index([en_string, category])
294+
@@index([category])
295+
@@index([completedLangs])
296+
}
297+
```
298+
299+
## Primary Language Configuration
300+
301+
The `primaryLanguage` option allows you to set the default language for your application. This is particularly useful when your application's primary language is not English.
302+
303+
### How Primary Language Works
304+
305+
- **Default Language**: The language shown to users by default
306+
- **Fallback Chain**: When a translation is missing, the system falls back to: `requested language``primaryLanguage``English`
307+
- **Translation Source**: English (`en_string`) is always used as the source for AI translations, regardless of the primary language setting
308+
309+
### Example: Portuguese as Primary Language
310+
311+
```ts title='./resources/translations.ts'
312+
export default {
313+
// ... other configuration
314+
plugins: [
315+
new I18nPlugin({
316+
// Set Portuguese as the primary language
317+
primaryLanguage: 'pt-BR',
318+
319+
supportedLanguages: ['pt-BR', 'en', 'en-GB', 'es', 'fr'],
320+
translationFieldNames: {
321+
'pt-BR': 'ptBR_string',
322+
en: 'en_string',
323+
'en-GB': 'enGB_string',
324+
es: 'es_string',
325+
fr: 'fr_string',
326+
},
327+
328+
// ... rest of configuration
329+
}),
330+
],
331+
// ... rest of configuration
332+
} as AdminForthResourceInput;
333+
```
208334
209335
## Translation for custom components
210336
@@ -458,6 +584,8 @@ If you don't use params, you can use `tr` without third param:
458584
}
459585
```
460586
587+
> 🔄 **Fallback Behavior**: The `tr` function automatically handles fallbacks when translations are missing. It follows this chain: `requested language``primaryLanguage``English`. This ensures users always see content in a language they can understand, even if specific translations are missing.
588+
461589
> 🙅‍♂️ Temporary limitation: For now all translations strings for backend (adminforth internal and for from custom APIs)
462590
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.
463591
> So to collect all translations you should use your app for some time and make sure all strings are used at

adminforth/spa/src/afcl/Table.vue

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,56 @@
116116
</template>
117117

118118
<script setup lang="ts">
119-
import { ref, type Ref, computed, useTemplateRef, watch, nextTick } from 'vue';
120-
import { asyncComputed } from '@vueuse/core';
119+
import { ref, computed, useTemplateRef, watch, shallowRef, toRef } from 'vue';
121120
import SkeleteLoader from '@/components/SkeleteLoader.vue';
122121
122+
type Row = Record<string, unknown>
123+
type LoadFn = (page: number, pageSize: number) => Promise<{ data: Row[]; total: number }>
124+
125+
const isFunc = (v: unknown): v is LoadFn => typeof v === 'function'
126+
127+
function usePagedData(props: {
128+
data: Row[] | LoadFn
129+
pageSize: number
130+
currentPage: number
131+
}) {
132+
const page = ref(props.currentPage)
133+
const pageSize = toRef(props, 'pageSize')
134+
135+
const isLoading = ref(false)
136+
const error = shallowRef<unknown>(null)
137+
const result = shallowRef<{ data: Row[]; total: number }>({ data: [], total: 0 })
138+
139+
let requestId = 0
140+
141+
async function fetchData() {
142+
const id = ++requestId
143+
isLoading.value = true
144+
error.value = null
145+
try {
146+
if (isFunc(props.data)) {
147+
const res = await props.data(page.value, pageSize.value)
148+
if (id !== requestId) return
149+
result.value = res
150+
} else {
151+
const start = (page.value - 1) * pageSize.value
152+
const end = start + pageSize.value
153+
result.value = { data: props.data.slice(start, end), total: props.data.length }
154+
}
155+
} catch (e) {
156+
if (id !== requestId) return
157+
error.value = e
158+
result.value = { data: [], total: 0 }
159+
} finally {
160+
if (id === requestId) isLoading.value = false
161+
}
162+
}
163+
164+
watch([page, pageSize, () => props.data], fetchData, { immediate: true })
165+
166+
return { page, pageSize, isLoading, error, result, refresh: fetchData }
167+
}
168+
123169
const props = withDefaults(
124170
defineProps<{
125171
columns: {
@@ -137,43 +183,34 @@
137183
}
138184
);
139185
140-
const currentPage = ref(1);
141-
const isLoading = ref(false);
186+
const { result: dataResult, isLoading, error, page: currentPage, pageSize, refresh } = usePagedData({
187+
data: props.data,
188+
pageSize: props.pageSize,
189+
currentPage: 1
190+
});
191+
142192
const pageInput = ref('1');
143193
const rowRefs = useTemplateRef<HTMLElement[]>('rowRefs');
144194
const headerRefs = useTemplateRef<HTMLElement[]>('headerRefs');
145195
const rowHeights = ref<number[]>([]);
146196
const columnWidths = ref<number[]>([]);
147197
148-
const dataResult = asyncComputed( async() => {
149-
if (typeof props.data === 'function') {
150-
isLoading.value = true;
151-
const result = await props.data(currentPage.value, props.pageSize);
152-
isLoading.value = false;
153-
return result;
154-
}
155-
const start = (currentPage.value - 1) * props.pageSize;
156-
const end = start + props.pageSize;
157-
return { data: props.data.slice(start, end), total: props.data.length };
158-
});
159-
160198
watch(() => currentPage.value, () => {
161-
// rows are set to null when new records are loading
162199
rowHeights.value = !rowRefs.value ? [] : rowRefs.value.map((el: HTMLElement) => el.offsetHeight);
163200
columnWidths.value = !headerRefs.value ? [] : headerRefs.value.map((el: HTMLElement) => el.offsetWidth);
164201
});
165202
166-
167203
const totalPages = computed(() => {
168204
return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1;
169205
});
170206
171-
const dataPage = asyncComputed( async() => {
207+
const dataPage = computed(() => {
172208
return dataResult.value.data;
173209
});
174210
175211
function switchPage(p: number) {
176212
currentPage.value = p;
213+
pageInput.value = p.toString();
177214
}
178215
179216
const emites = defineEmits([
@@ -191,6 +228,10 @@
191228
pageInput.value = validPage.toString();
192229
}
193230
231+
watch(() => currentPage.value, (newPage) => {
232+
pageInput.value = newPage.toString();
233+
});
234+
194235
async function onPageKeydown(event: any) {
195236
// page input should accept only numbers, arrow keys and backspace
196237
if (['Enter', 'Space'].includes(event.code) ||

0 commit comments

Comments
 (0)