Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge custom translations into client app i18n #592

Merged
merged 6 commits into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions agate-ui/src/boot/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { boot } from 'quasar/wrappers';
import { createI18n } from 'vue-i18n';
import messages from 'src/i18n';
import { Quasar, Cookies } from 'quasar';
import { translationAsMap } from 'src/utils/translations';
import type { AttributeDto } from 'src/models/Agate';

export type MessageLanguages = keyof typeof messages;
// Type-define 'en-US' as the master schema for the resource
Expand Down Expand Up @@ -40,6 +42,28 @@ function getCurrentLocale(): string {
return detectedLocale || locales[0] || 'en';
}

function mergeWithCustomMessages() {
const serverTranslations = translationAsMap(systemStore.configuration.translations || []);

Object.keys(serverTranslations).forEach((lang) => {
const existingMessages = i18n.global.getLocaleMessage(lang) || {};
const newMessages = (serverTranslations[lang] || ([] as AttributeDto[])).reduce(
(acc, tr) => {
if (tr.name && tr.value) {
acc[tr.name] = tr.value;
}
return acc;
},
{} as Record<string, string>,
);

i18n.global.setLocaleMessage(lang, {
...existingMessages,
...newMessages,
});
});
}

const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({
locale: getCurrentLocale(),
fallbackLocale: locales[0] || 'en',
Expand All @@ -53,6 +77,17 @@ export default boot(({ app }) => {
app.use(i18n);
});

const systemStore = useSystemStore();

watch(
() => systemStore.configuration.translations,
(newValue) => {
if (newValue) {
mergeWithCustomMessages();
}
},
);

const t = i18n.global.t;

export { i18n, t, locales, getCurrentLocale };
185 changes: 185 additions & 0 deletions agate-ui/src/components/SystemCustomTranslations.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<template>
<div>
<div class="q-px-md">
<q-tabs
v-model="selectedLanguage"
dense
class="text-grey"
active-color="primary"
indicator-color="primary"
align="justify"
>
<q-tab v-for="language in languages" :key="language" :name="language" :label="language" />
</q-tabs>

<q-separator />
</div>

<div class="q-px-md">
<q-table
:rows="translations"
flat
row-key="name"
:columns="columns"
:pagination="initialPagination"
:hide-pagination="translations.length <= initialPagination.rowsPerPage"
selection="multiple"
v-model:selected="selectedTranslations"
>
<template v-slot:top-left>
<div class="q-gutter-md">
<q-btn size="sm" icon="add" color="primary" :label="t('add')" @click="onAdd" />
<q-btn
size="sm"
icon="delete"
color="negative"
:label="t('delete')"
:disable="selectedTranslations.length < 1"
@click="onDelete"
/>
</div>
</template>
<template v-slot:top-right>
<q-input v-model="filter" debounce="300" :placeholder="t('search')" dense clearable class="q-mr-md">
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:body-cell-name="props">
<q-td :props="props">
<span>{{ props.value }}</span>
</q-td>
</template>
<template v-slot:body-cell-value="props">
<q-td :props="props">
<q-input type="text" v-model="props.row.value" dense @update:model-value="onValueChanged(props.row)" />
</q-td>
</template>
</q-table>
<div v-if="dirty" class="box-warning q-mt-md row items-center justify-center">
<div class="col">{{ t('system.translations.apply_changes') }}</div>
<div class="col-auto">
<q-btn size="sm" icon="check" color="secondary" :label="t('apply')" :disable="!dirty" @click="onApply" />
</div>
</div>
</div>

<confirm-dialog
v-model="showDelete"
:title="t('user.remove')"
:text="t('system.translations.remove_confirm', { count: selectedTranslations.length })"
@confirm="doDelete"
/>

<system-custom-translations-dialog
v-model="showAdd"
:translation-keys="translationKeys"
:language="languages[0] || systemStore.defaultLanguage"
@added="onAdded"
@cancel="showAdd = false"
/>
</div>
</template>

<script setup lang="ts">
import type { AttributeDto } from 'src/models/Agate';
import { DefaultAlignment } from 'src/components/models';
import { translationAsMap, mapAsTranslation } from 'src/utils/translations';
import ConfirmDialog from 'src/components/ConfirmDialog.vue';
import SystemCustomTranslationsDialog from 'src/components/SystemCustomTranslationsDialog.vue';

const systemStore = useSystemStore();
const { t } = useI18n();

const initialPagination = ref({
descending: false,
page: 1,
rowsPerPage: 10,
});

const filter = ref('');
const dirty = ref(false);
const showAdd = ref(false);
const showDelete = ref(false);
const allTranslations = ref<Record<string, AttributeDto[]>>({});
const selectedLanguage = ref();
const translations = computed(
() =>
allTranslations.value[selectedLanguage.value]?.filter((app) =>
filter.value ? app.name.toLowerCase().includes(filter.value.toLowerCase()) : true,
) || [],
);
const translationKeys = computed(() => (allTranslations.value[systemStore.defaultLanguage] || []).map((x) => x.name));
const selectedTranslations = ref<AttributeDto[]>([]);
const languages = computed<string[]>(() => systemStore.configuration.languages || []);
const columns = computed(() => [
{ name: 'name', label: t('name'), field: 'name', align: DefaultAlignment },
{ name: 'value', label: t('value'), field: 'value', align: DefaultAlignment },
]);

function onValueChanged(row: AttributeDto) {
dirty.value = true;
if (!row.value || row.value.length === 0) {
row.value = row.name;
}
}

function onAdd() {
showAdd.value = true;
}

function onAdded(newTranslation: AttributeDto) {
selectedTranslations.value.splice(0);
dirty.value = true;
showAdd.value = false;

languages.value.forEach((lang) => {
if (!allTranslations.value[lang]) {
allTranslations.value[lang] = [];
}

allTranslations.value[lang].push({
name: newTranslation.name,
value: newTranslation.value || newTranslation.name,
});
});
}

function onApply() {
dirty.value = false;
selectedTranslations.value.splice(0);
systemStore.updateTranslation(mapAsTranslation(allTranslations.value)).then(() => {
systemStore.init();
});
}

function onDelete() {
showDelete.value = true;
}

function doDelete() {
dirty.value = true;
selectedTranslations.value.forEach((translation) => {
languages.value.forEach((lang) => {
if (allTranslations.value[lang]) {
allTranslations.value[lang] = allTranslations.value[lang].filter((x) => x.name !== translation.name);
}
});
});

selectedTranslations.value.splice(0);
showDelete.value = false;
}

watch(
() => systemStore.configuration.translations,
(newValue) => {
if (newValue) {
selectedLanguage.value = systemStore.defaultLanguage;
allTranslations.value = translationAsMap(newValue);
}
},
{ immediate: true },
);
</script>
96 changes: 96 additions & 0 deletions agate-ui/src/components/SystemCustomTranslationsDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<template>
<q-dialog v-model="showDialog" persistent @hide="onHide">
<q-card class="dialog-md">
<q-card-section>
<div class="text-h6">{{ t('system.translations.add') }}</div>
</q-card-section>

<q-separator />

<q-card-section>
<div class="text-help q-mb-md">{{ t('system.translations.add_hint', {language: language.toUpperCase()}) }}</div>
<q-form ref="formRef">
<q-input
v-model="newAttribue.name"
dense
type="text"
:label="t('name') + ' *'"
class="q-mb-md"
lazy-rules
:rules="[validateRequired, validateUnique]"
>
</q-input>
<q-input v-model="newAttribue.value" :label="t('value')" class="q-mb-md" dense type="text" lazy-rules />
</q-form>
</q-card-section>

<q-separator />

<q-card-actions align="right" class="bg-grey-3">
<q-btn flat :label="t('cancel')" color="secondary" @click="onCancel" v-close-popup />
<q-btn flat :label="t('save')" color="primary" @click="onSave" />
</q-card-actions>
</q-card>
</q-dialog>
</template>

<script setup lang="ts">
import type { AttributeDto } from 'src/models/Agate';

const { t } = useI18n();

interface DialogProps {
modelValue: boolean;
translationKeys: string[];
language: string;
}

const props = defineProps<DialogProps>();
const emit = defineEmits(['update:modelValue', 'added', 'cancel']);
const formRef = ref();
const showDialog = ref(props.modelValue);
const newAttribue = ref<AttributeDto>({} as AttributeDto);

function validateRequired(value: string) {
return !!value || t('name_required');
}

function validateUnique(value: string) {
if (value) {
const exists = props.translationKeys.find((key) => key === value);
return !exists || t('system.translations.name_exists');
}
return true;
}

watch(
() => props.modelValue,
(value) => {
if (value) {
newAttribue.value = {} as AttributeDto;
}

showDialog.value = value;
},
);

function onHide() {
emit('update:modelValue', false);
}

function onCancel() {
emit('cancel');
}

async function onSave() {
const valid = await formRef.value.validate();
if (valid) {
if (!newAttribue.value.value) {
newAttribue.value.value = newAttribue.value.name;
}

emit('added', newAttribue.value);
emit('update:modelValue', false);
}
}
</script>
14 changes: 13 additions & 1 deletion agate-ui/src/i18n/en/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,23 @@ export default {
signup_whitelist: 'Signup whitelist',
sso_domain_hint: 'Single sign-on domain.',
sso_domain: 'SSO Domain',
translations: {
title: "Custom Translations",
hint: 'Override the default translations or add new ones.',
name_exists: 'Translation key already exists',
add: 'Add Translation',
add_hint: 'Add \'{language}\' translation - other languages can be edited in Settings.',
remove_confirm: 'Please confirm the removal of the translation | Please confirm the removal of the {count} translations',
apply_changes: 'Click Apply to save changes',
},
otp_strategies: {
NONE: 'None',
APP: 'Mobile app',
ANY: 'Email or mobile app',
},
attributes: {
title: 'Custom User Attributes',
hint: 'Extend user profile with custom attributes.',
hint: 'Add custom attributes to the user profile and localize them with \'user-info.\'-prefixed keys in Custom Translations (e.g., \'user-info.institution\').',
values_hint: 'Comma separated values',
add: 'Add Attribute',
update: 'Update Attribute',
Expand Down Expand Up @@ -275,7 +284,9 @@ export default {
administration: 'Administration',
applications_caption: 'Manage applications, identifications and identity providers',
applications: 'Applications',
apply: 'Apply',
cancel: 'Cancel',
confirm: 'Confirm',
content_management: 'Content Management',
delete: 'Remove',
description: 'Description',
Expand Down Expand Up @@ -315,6 +326,7 @@ export default {
tickets: 'Tickets',
translations: 'Translations',
type: 'Type',
update: 'Update',
username: 'Username',
users_caption: 'Manage users, assign role and groups to grant applications access',
users: 'Users',
Expand Down
Loading