Add App Translation Strings in Settings (#12170)

* add migration for translation strings

* add to navigation

* WIP

* fix dialog overflow

* update translation keys

* Update logic

* add placeholder to system-language

* fix translation

* remove unused import

* reset dialog on create new

* ensure search input is visible when searching

* merge translation strings on set language

* merge translation strings on update

* hydrate

* make sure null translation do not get merged

* change dialog to drawer

* update placeholder text

* fix form value

* revert dialog style change

* rename drawer component

* Force safe key name

* Move interface to system interfaces

The saved values are Directus app proprietary, so to prevent confusion in what it's supposed to do, we'll move it to system.

* Move composable to root composables

* Use new languages input in interface/display options

* hide translation strings field in project settings

* set system true to system-input-translated-string

* use this in field detail notes

* use in list options

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Azri Kahar
2022-03-22 21:39:04 +08:00
committed by GitHub
parent e2eb2801c5
commit 37cbaa0be5
37 changed files with 1035 additions and 34 deletions

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_settings', (table) => {
table.json('translation_strings');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_settings', (table) => {
table.dropColumn('translation_strings');
});
}

View File

@@ -378,3 +378,6 @@ fields:
interface: input
options:
placeholder: $t:fields.directus_settings.attribution_placeholder
- field: translation_strings
hidden: true

View File

@@ -0,0 +1,151 @@
import { ref, Ref } from 'vue';
import api from '@/api';
import { unexpectedError } from '@/utils/unexpected-error';
import { Language, i18n } from '@/lang';
import { useUserStore } from '@/stores';
export type Translation = {
language: string;
translation: string;
};
export type TranslationStringRaw = {
key?: string | null;
translations?: Record<string, string> | null;
};
export type TranslationString = {
key?: string | null;
translations?: Translation[] | null;
};
type UsableTranslationStrings = {
loading: Ref<boolean>;
error: Ref<any>;
translationStrings: Ref<TranslationString[] | null>;
refresh: () => Promise<void>;
updating: Ref<boolean>;
update: (newTranslationStrings: TranslationString[]) => Promise<void>;
mergeTranslationStringsForLanguage: (lang: Language) => void;
};
let loading: Ref<boolean> | null = null;
let translationStrings: Ref<TranslationString[] | null> | null = null;
let error: Ref<any> | null = null;
export function useTranslationStrings(): UsableTranslationStrings {
if (loading === null) loading = ref(false);
if (error === null) error = ref(null);
if (translationStrings === null) translationStrings = ref<TranslationString[] | null>(null);
const updating = ref(false);
return { loading, error, translationStrings, refresh, updating, update, mergeTranslationStringsForLanguage };
async function fetchTranslationStrings() {
if (loading === null) return;
if (translationStrings === null) return;
if (error === null) return;
loading.value = true;
error.value = null;
try {
const response = await api.get('/settings', {
params: {
fields: ['translation_strings'],
},
});
const { translation_strings } = response.data.data;
if (translation_strings) {
translationStrings.value = translation_strings.map((p: TranslationStringRaw) => ({
key: p.key,
translations: getTranslationsFromKeyValues(p.translations ?? null),
}));
}
} catch (err: any) {
error.value = err;
} finally {
loading.value = false;
}
}
async function refresh() {
if (loading === null) return;
if (translationStrings === null) return;
if (error === null) return;
loading.value = false;
error.value = null;
await fetchTranslationStrings();
}
async function update(newTranslationStrings: TranslationString[]) {
if (loading === null) return;
if (translationStrings === null) return;
if (error === null) return;
updating.value = true;
const resultingTranslationStrings = getUniqueTranslationStrings([...newTranslationStrings]);
const payload = resultingTranslationStrings.map((p: TranslationString) => ({
key: p.key,
translations: getKeyValuesFromTranslations(p.translations),
}));
try {
const settingsResponse = await api.patch('/settings', {
translation_strings: payload,
});
if (settingsResponse.data.data.translation_strings) {
translationStrings.value = settingsResponse.data.data.translation_strings.map((p: TranslationStringRaw) => ({
key: p.key,
translations: getTranslationsFromKeyValues(p.translations ?? null),
}));
const { currentUser } = useUserStore();
if (currentUser && 'language' in currentUser && currentUser.language) {
mergeTranslationStringsForLanguage(currentUser.language);
} else {
mergeTranslationStringsForLanguage('en-US');
}
}
} catch (err: any) {
unexpectedError(err);
} finally {
updating.value = false;
}
}
function mergeTranslationStringsForLanguage(lang: Language) {
if (!translationStrings?.value) return;
const localeMessages: Record<string, any> = translationStrings.value.reduce((acc, cur) => {
if (!cur.key || !cur.translations) return acc;
const translationForCurrentLang = cur.translations.find((t) => t.language === lang);
if (!translationForCurrentLang || !translationForCurrentLang.translation) return acc;
return { ...acc, [cur.key]: translationForCurrentLang.translation };
}, {});
i18n.global.mergeLocaleMessage(lang, localeMessages);
}
function getUniqueTranslationStrings(arr: TranslationString[]): TranslationString[] {
return [...new Map(arr.map((item: TranslationString) => [item.key, item])).values()];
}
function getKeyValuesFromTranslations(val: TranslationString['translations'] | null): Record<string, string> {
if (!val || (val && val.length === 0)) return {};
return val.reduce((acc, cur) => {
return { ...acc, [cur.language]: cur.translation };
}, {});
}
function getTranslationsFromKeyValues(val: Record<string, string> | null): TranslationString['translations'] {
if (!val || Object.keys(val).length === 0) return [];
return Object.entries(val).map(([k, v]) => ({ language: k, translation: v }));
}
}

View File

@@ -14,7 +14,7 @@ export default defineDisplay({
name: '$t:displays.boolean.label_on',
type: 'string',
meta: {
interface: 'input',
interface: 'system-input-translated-string',
width: 'half',
options: {
placeholder: '$t:displays.boolean.label_on_placeholder',
@@ -26,7 +26,7 @@ export default defineDisplay({
name: '$t:displays.boolean.label_off',
type: 'string',
meta: {
interface: 'input',
interface: 'system-input-translated-string',
width: 'half',
options: {
placeholder: '$t:displays.boolean.label_off_placeholder',

View File

@@ -84,7 +84,7 @@ export default defineDisplay({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
label: '$t:displays.formatted-value.prefix_label',
trim: false,
@@ -97,7 +97,7 @@ export default defineDisplay({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
label: '$t:displays.formatted-value.suffix_label',
trim: false,
@@ -209,7 +209,7 @@ export default defineDisplay({
name: '$t:displays.formatted-value.text',
type: 'string',
meta: {
interface: 'input',
interface: 'system-input-translated-string',
width: 'half',
options: {
placeholder: '$t:displays.formatted-value.text_placeholder',

View File

@@ -50,7 +50,7 @@ export default defineDisplay({
name: '$t:text',
type: 'string',
meta: {
interface: 'input',
interface: 'system-input-translated-string',
width: 'half',
options: {
placeholder: '$t:displays.labels.choices_text_placeholder',

View File

@@ -16,6 +16,7 @@ import {
useUserStore,
useNotificationsStore,
} from '@/stores';
import { useTranslationStrings } from '@/composables/use-translation-strings';
type GenericStore = {
$id: string;
@@ -50,6 +51,7 @@ export async function hydrate(): Promise<void> {
const appStore = useAppStore();
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const { refresh: hydrateTranslationStrings } = useTranslationStrings();
if (appStore.hydrated) return;
if (appStore.hydrating) return;
@@ -71,6 +73,7 @@ export async function hydrate(): Promise<void> {
await Promise.all(stores.filter(({ $id }) => !hydratedStores.includes($id)).map((store) => store.hydrate?.()));
await registerModules();
await hydrateTranslationStrings();
await setLanguage(userStore.currentUser?.language ?? 'en-US');
}

View File

@@ -0,0 +1,29 @@
import { defineInterface } from '@directus/shared/utils';
import InterfaceInputTranslatedString from './input-translated-string.vue';
import PreviewSVG from './preview.svg?raw';
export default defineInterface({
id: 'system-input-translated-string',
name: '$t:interfaces.input-translated-string.input-translated-string',
description: '$t:interfaces.input-translated-string.description',
icon: 'translate',
component: InterfaceInputTranslatedString,
system: true,
types: ['string', 'text'],
group: 'standard',
preview: PreviewSVG,
options: [
{
field: 'placeholder',
name: '$t:placeholder',
type: 'string',
meta: {
width: 'half',
interface: 'input',
options: {
placeholder: '$t:enter_a_placeholder',
},
},
},
],
});

View File

@@ -0,0 +1,261 @@
<template>
<div class="input-translated-string">
<v-menu ref="menuEl" :disabled="disabled" :close-on-content-click="false" attached>
<template #activator="{ toggle, active }">
<v-input
class="translation-input"
:model-value="localValue"
:placeholder="placeholder"
:disabled="disabled"
:active="active"
@update:model-value="localValue = $event"
>
<template v-if="hasValidKey" #input>
<button :disabled="disabled" @click.stop="setValue(null)">{{ value && getKeyWithoutPrefix(value) }}</button>
</template>
<template #append>
<v-icon
name="translate"
class="translate-icon"
:class="{ active }"
clickable
:disabled="disabled"
@click="toggle"
/>
</template>
</v-input>
</template>
<div v-if="searchValue !== null || translations.length >= 25" class="search">
<v-input
class="search-input"
type="text"
:model-value="searchValue"
autofocus
:placeholder="t('interfaces.input-translated-string.search_placeholder')"
@update:model-value="searchValue = $event"
>
<template #append>
<v-icon name="search" class="search-icon" />
</template>
</v-input>
</div>
<v-list>
<v-list-item
v-for="translation in translations"
:key="translation.key"
class="translation-key"
:class="{ selected: localValue && translation.key === localValueWithoutPrefix }"
clickable
@click="selectKey(translation.key!)"
>
<v-list-item-icon>
<v-icon name="translate" />
</v-list-item-icon>
<v-list-item-content><v-highlight :text="translation.key" :query="searchValue" /></v-list-item-content>
<v-list-item-icon class="info">
<TranslationStringsTooltip :translations="translation.translations" hide-display-text />
</v-list-item-icon>
</v-list-item>
<v-list-item class="new-translation-string" clickable @click="openNewTranslationStringDialog">
<v-list-item-icon>
<v-icon name="add" />
</v-list-item-icon>
<v-list-item-content>
{{ t('interfaces.input-translated-string.new_translation_string') }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<TranslationStringsDrawer
:model-value="isTranslationStringDialogOpen"
:translation-string="editingTranslationString"
@update:model-value="updateTranslationStringsDialog"
@saved-key="setValue(`${translationPrefix}${$event}`)"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTranslationStrings, TranslationString } from '@/composables/use-translation-strings';
import TranslationStringsDrawer from '@/modules/settings/routes/translation-strings/translation-strings-drawer.vue';
import TranslationStringsTooltip from '@/modules/settings/routes/translation-strings/translation-strings-tooltip.vue';
const translationPrefix = '$t:';
interface Props {
value?: string | null;
disabled?: boolean;
placeholder?: string | null;
}
const props = withDefaults(defineProps<Props>(), { value: () => null, disabled: false, placeholder: () => null });
const emit = defineEmits(['input']);
const { t } = useI18n();
const menuEl = ref();
const hasValidKey = ref<boolean>(false);
const searchValue = ref<string | null>(null);
const { translationStrings } = useTranslationStrings();
const isTranslationStringDialogOpen = ref<boolean>(false);
const editingTranslationString = ref<TranslationString | null>(null);
const translations = computed(() => {
const keys = translationStrings.value ?? [];
return !searchValue.value ? keys : keys.filter((key) => key.key?.includes(searchValue.value!));
});
const localValue = computed<string | null>({
get() {
return props.value;
},
set(val) {
if (props.value === val) return;
emit('input', val);
},
});
watch(
() => props.value,
(newVal) => setValue(newVal),
{ immediate: true }
);
const localValueWithoutPrefix = computed(() => (localValue.value ? getKeyWithoutPrefix(localValue.value) : null));
function getKeyWithoutPrefix(val: string) {
return val.substring(translationPrefix.length);
}
function selectKey(key: string) {
setValue(`${translationPrefix}${key}`);
menuEl.value.deactivate();
searchValue.value = null;
}
function setValue(newValue: any) {
hasValidKey.value = false;
if (
newValue &&
newValue.startsWith(translationPrefix) &&
translations.value.find((t) => t.key?.includes(getKeyWithoutPrefix(newValue)))
) {
hasValidKey.value = true;
}
localValue.value = newValue;
}
function openNewTranslationStringDialog() {
menuEl.value.deactivate();
isTranslationStringDialogOpen.value = true;
}
function updateTranslationStringsDialog(val: boolean) {
if (val) return;
editingTranslationString.value = null;
isTranslationStringDialogOpen.value = val;
}
</script>
<style lang="scss" scoped>
.translation-input {
:deep(button) {
margin-right: auto;
padding: 2px 8px 0;
color: var(--primary);
background-color: var(--primary-alt);
border-radius: var(--border-radius);
transition: var(--fast) var(--transition);
transition-property: background-color, color;
user-select: none;
font-family: var(--family-monospace);
}
:deep(button:not(:disabled):hover) {
color: var(--white);
background-color: var(--danger);
}
.translate-icon {
&:hover,
&.active {
--v-icon-color-hover: var(--primary);
--v-icon-color: var(--primary);
}
}
}
.search {
padding: 12px 8px 6px 8px;
.search-input {
--input-height: 48px;
}
.search-icon {
pointer-events: none;
}
}
.translation-key {
transition: color var(--fast) var(--transition);
.info :deep(.icon) {
transition: opacity var(--fast) var(--transition);
opacity: 0;
}
&:hover .info :deep(.icon) {
opacity: 1;
}
:deep(mark) {
flex-basis: auto;
flex-grow: 0;
flex-shrink: 1;
color: var(--primary);
}
&.selected {
--v-list-item-color-active: var(--foreground-inverted);
--v-list-item-background-color-active: var(--primary);
--v-list-item-color-hover: var(--foreground-inverted);
--v-list-item-background-color-hover: var(--primary);
background-color: var(--primary);
color: var(--foreground-inverted);
.v-list-item-icon {
--v-icon-color: var(--foreground-inverted);
}
.info :deep(.icon) {
color: var(--foreground-inverted);
opacity: 1;
}
}
}
.new-translation-string {
--v-list-item-color-hover: var(--primary-125);
color: var(--primary);
.v-list-item-icon {
--v-icon-color: var(--primary);
}
}
</style>

View File

@@ -0,0 +1,11 @@
<svg width="156" height="96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="18" y="17.037" width="120" height="62" rx="6" fill="var(--background-page)" />
<rect x="19" y="18.037" width="118" height="60" rx="5" stroke="var(--primary)" stroke-opacity=".25"
stroke-width="2" />
<rect x="28" y="51" width="40" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="28" y="63" width="60" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="18" y="17" width="120" height="26" rx="6" fill="var(--background-page)" class="glow" />
<rect x="19" y="18" width="118" height="24" rx="5" stroke="var(--primary)" stroke-width="2" />
<rect x="28" y="27" width="70" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<path d="m124.52 31.78-1.5-1.45.02-.03a10 10 0 0 0 2.16-3.8h1.73v-1.18h-4.1v-1.14h-1.15v1.14h-4.1v1.18h6.53a9.34 9.34 0 0 1-1.86 3.12 9.14 9.14 0 0 1-1.34-1.94h-1.18a10.89 10.89 0 0 0 1.75 2.65l-2.98 2.92.82.82 2.93-2.9 1.8 1.81.47-1.2zm3.28-2.96h-1.17l-2.63 7h1.18l.65-1.75h2.76l.66 1.75h1.18l-2.63-7zm-1.53 4.1.93-2.54.96 2.55h-1.89z" fill="var(--primary)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,9 +1,16 @@
<template>
<v-select :model-value="value" :items="languages" :disabled="disabled" @update:model-value="$emit('input', $event)" />
<v-select
:model-value="value"
:items="languages"
:disabled="disabled"
:placeholder="t('language_placeholder')"
@update:model-value="$emit('input', $event)"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import availableLanguages from '@/lang/available-languages.yaml';
export default defineComponent({
@@ -19,12 +26,14 @@ export default defineComponent({
},
emits: ['input'],
setup() {
const { t } = useI18n();
const languages = Object.entries(availableLanguages).map(([key, value]) => ({
text: value,
value: key,
}));
return { languages };
return { t, languages };
},
});
</script>

View File

@@ -60,7 +60,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:interfaces.boolean.label_placeholder',
},

View File

@@ -106,7 +106,7 @@ export default defineInterface({
name: '$t:placeholder',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -18,7 +18,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -17,7 +17,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'full',
interface: 'input-multiline',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -92,7 +92,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -19,7 +19,7 @@ export default defineInterface({
name: '$t:placeholder',
meta: {
width: 'full',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},
@@ -178,7 +178,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -7,7 +7,12 @@
<div class="grid-element half">
<p class="type-label">{{ t('interfaces.list.add_label') }}</p>
<v-input v-model="addLabel" class="input" :placeholder="t('create_new')" />
<interface-system-input-translated-string
:value="addLabel"
class="input"
:placeholder="t('create_new')"
@input="addLabel = $event"
/>
</div>
<div class="grid-element full">
@@ -135,7 +140,7 @@ export default defineComponent({
field: 'note',
type: 'string',
meta: {
interface: 'input',
interface: 'system-input-translated-string',
width: 'full',
sort: 6,
options: {

View File

@@ -20,7 +20,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'full',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:interfaces.presentation-divider.title_placeholder',
},

View File

@@ -28,7 +28,7 @@ export default defineInterface({
name: '$t:label',
meta: {
width: 'full',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:label',
},

View File

@@ -50,7 +50,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'full',
interface: 'input-multiline',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:interfaces.presentation-notice.text',
},

View File

@@ -40,7 +40,7 @@ export default defineInterface({
type: 'string',
name: '$t:name',
meta: {
interface: 'input',
interface: 'system-input-translated-string',
width: 'half',
options: {
placeholder: '$t:interfaces.select-color.name_placeholder',

View File

@@ -97,7 +97,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -10,7 +10,7 @@ const repeaterFields: DeepPartial<Field>[] = [
name: '$t:text',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:interfaces.select-dropdown.choices_name_placeholder',
},

View File

@@ -28,7 +28,7 @@ export default defineInterface({
name: '$t:text',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:interfaces.select-dropdown.choices_name_placeholder',
},

View File

@@ -29,7 +29,7 @@ export default defineInterface({
name: '$t:text',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:interfaces.select-dropdown.choices_name_placeholder',
},
@@ -88,7 +88,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -29,7 +29,7 @@ export default defineInterface({
name: '$t:text',
meta: {
width: 'half',
interface: 'input',
interface: 'system-input-translated-string',
},
},
{

View File

@@ -26,7 +26,7 @@ export default defineInterface({
type: 'string',
meta: {
width: 'full',
interface: 'input',
interface: 'system-input-translated-string',
options: {
placeholder: '$t:enter_a_placeholder',
},

View File

@@ -7,6 +7,7 @@ import { useCollectionsStore, useFieldsStore } from '@/stores';
import { translate } from '@/utils/translate-object-values';
import availableLanguages from './available-languages.yaml';
import { i18n, Language, loadedLanguages } from './index';
import { useTranslationStrings } from '@/composables/use-translation-strings';
const { modules, modulesRaw } = getModules();
const { layouts, layoutsRaw } = getLayouts();
@@ -17,6 +18,7 @@ const { displays, displaysRaw } = getDisplays();
export async function setLanguage(lang: Language): Promise<boolean> {
const collectionsStore = useCollectionsStore();
const fieldsStore = useFieldsStore();
const { mergeTranslationStringsForLanguage } = useTranslationStrings();
if (Object.keys(availableLanguages).includes(lang) === false) {
// eslint-disable-next-line no-console
@@ -46,6 +48,7 @@ export async function setLanguage(lang: Language): Promise<boolean> {
collectionsStore.translateCollections();
fieldsStore.translateFields();
mergeTranslationStringsForLanguage(lang);
return true;
}

View File

@@ -70,6 +70,12 @@ create_role: Create Role
create_user: Create User
delete_panel: Delete Panel
create_webhook: Create Webhook
create_translation_string: Create Translation String
translation_string_key_placeholder: Translation string key...
translation_string_translations_placeholder: Add a new translation
edit_translation_string: Edit Translation String
delete_translation_string_copy: >-
Are you sure you want to delete the translation string "{key}"? This action can not be undone.
invite_users: Invite Users
invite: Invite
email_already_invited: Email "{email}" has already been invited
@@ -180,6 +186,7 @@ field_create_success: 'Created Field: "{field}"'
field_update_success: 'Updated Field: "{field}"'
duplicate_where_to: Where would you like to duplicate this field to?
language: Language
language_placeholder: Select a language...
aggregate_function: Aggregate Function
aggregate_precision: Aggregate Precision
group_aggregation: Group Aggregation
@@ -828,6 +835,7 @@ settings_permissions: Roles & Permissions
settings_project: Project Settings
settings_webhooks: Webhooks
settings_presets: Presets & Bookmarks
settings_translation_strings: Translation Strings
one_or_more_options_are_missing: One or more options are missing
scope: Scope
select: Select...
@@ -878,6 +886,7 @@ page_help_settings_presets_item: >-
**Preset Detail** — A form for managing bookmarks and default collection presets.
page_help_settings_webhooks_collection: '**Browse Webhooks** — Lists all webhooks within the project.'
page_help_settings_webhooks_item: '**Webhook Detail** — A form for creating and managing project webhooks.'
page_help_settings_translation_strings_collection: '**Browse Translation Strings** — Lists all translation strings within the project.'
page_help_users_collection: '**User Directory** — Lists all system users within this project.'
page_help_users_item: >-
**User Detail** — Manage your account information, or view the details of other users.
@@ -1534,6 +1543,11 @@ interfaces:
input-multiline:
textarea: Textarea
description: Enter multiline plain-text
input-translated-string:
input-translated-string: Translated Strings
description: Select or add translation string
search_placeholder: Search...
new_translation_string: New Translation String
boolean:
toggle: Toggle
description: Switch between on and off

View File

@@ -58,6 +58,11 @@ export default defineComponent({
name: t('settings_presets'),
to: `/settings/presets`,
},
{
icon: 'translate',
name: t('settings_translation_strings'),
to: `/settings/translation-strings`,
},
{
icon: 'anchor',
name: t('settings_webhooks'),

View File

@@ -17,6 +17,7 @@ import RolesPermissionsDetail from './routes/roles/permissions-detail/permission
import RolesPublicItem from './routes/roles/public-item.vue';
import WebhooksCollection from './routes/webhooks/collection.vue';
import WebhooksItem from './routes/webhooks/item.vue';
import TranslationStringsCollection from './routes/translation-strings/collection.vue';
export default defineModule({
id: 'settings',
@@ -175,6 +176,17 @@ export default defineModule({
},
],
},
{
path: 'translation-strings',
component: RouterPass,
children: [
{
name: 'settings-translation-strings-collection',
path: '',
component: TranslationStringsCollection,
},
],
},
{
name: 'settings-not-found',
path: ':_(.+)+',

View File

@@ -17,7 +17,7 @@
<div v-if="type !== 'group'" class="field full">
<div class="label type-label">{{ t('note') }}</div>
<v-input v-model="note" :placeholder="t('add_note')" />
<interface-system-input-translated-string :value="note" :placeholder="t('add_note')" @input="note = $event" />
</div>
<div class="field full">
@@ -72,20 +72,15 @@ import { storeToRefs } from 'pinia';
export default defineComponent({
setup() {
const { t } = useI18n();
const fieldDetailStore = useFieldDetailStore();
const readonly = syncFieldDetailStoreProperty('field.meta.readonly', false);
const hidden = syncFieldDetailStoreProperty('field.meta.hidden', false);
const required = syncFieldDetailStoreProperty('field.meta.required', false);
const note = syncFieldDetailStoreProperty('field.meta.note');
const translations = syncFieldDetailStoreProperty('field.meta.translations');
const { field } = storeToRefs(fieldDetailStore);
const type = computed(() => field.value.type);
const isGenerated = computed(() => field.value.schema?.is_generated);
return { t, readonly, hidden, required, note, translations, type, isGenerated };
},
});

View File

@@ -0,0 +1,136 @@
<template>
<private-view :title="t('settings_translation_strings')">
<template #headline><v-breadcrumb :items="[{ name: t('settings'), to: '/settings' }]" /></template>
<template #title-outer:prepend>
<v-button class="header-icon" rounded disabled icon secondary>
<v-icon name="translate" />
</v-button>
</template>
<template #actions>
<v-button v-tooltip.bottom="t('create_translation_string')" rounded icon @click="openTranslationStringDialog">
<v-icon name="add" />
</v-button>
</template>
<template #navigation>
<settings-navigation />
</template>
<template #sidebar>
<sidebar-detail icon="info_outline" :title="t('information')" close>
<div v-md="t('page_help_settings_translation_strings_collection')" class="page-description" />
</sidebar-detail>
</template>
<div class="translation-strings">
<v-table
:headers="tableHeaders"
fixed-header
item-key="key"
:items="tableItems"
:loading="loading"
@click:row="openTranslationStringDialog"
>
<template #[`item.key`]="{ item }">
<span class="key">
{{ item.key }}
</span>
</template>
<template #[`item.translations`]="{ item }">
<TranslationStringsTooltip :translations="item.translations" />
</template>
</v-table>
</div>
<TranslationStringsDrawer
:model-value="isTranslationStringDialogOpen"
:translation-string="editingTranslationString"
@update:model-value="updateTranslationStringsDialog"
/>
</private-view>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { HeaderRaw as TableHeader } from '@/components/v-table/types';
import SettingsNavigation from '../../components/navigation.vue';
import { TranslationString, useTranslationStrings } from '@/composables/use-translation-strings';
import TranslationStringsDrawer from './translation-strings-drawer.vue';
import TranslationStringsTooltip from './translation-strings-tooltip.vue';
const { t } = useI18n();
const tableHeaders: TableHeader[] = [
{
text: t('key'),
value: 'key',
sortable: false,
width: 250,
align: 'left',
},
{
text: t('translations'),
value: 'translations',
sortable: false,
width: 800,
align: 'left',
},
];
const isTranslationStringDialogOpen = ref<boolean>(false);
const editingTranslationString = ref<TranslationString | null>(null);
const { loading, translationStrings } = useTranslationStrings();
const tableItems = computed(() => (translationStrings.value ? translationStrings.value : []));
function openTranslationStringDialog({ item }: { item?: TranslationString }) {
editingTranslationString.value = item ? item : null;
isTranslationStringDialogOpen.value = true;
}
function updateTranslationStringsDialog(val: boolean) {
if (val) return;
editingTranslationString.value = null;
isTranslationStringDialogOpen.value = val;
}
</script>
<style lang="scss" scoped>
.header-icon {
--v-button-color-disabled: var(--primary);
--v-button-background-color-disabled: var(--primary-10);
}
.translation-strings {
padding: var(--content-padding);
padding-top: 0;
padding-bottom: var(--content-padding-bottom);
}
.search-input {
--input-height: 44px;
:deep(.input) {
border-radius: 22px !important;
}
&.active {
width: 300px;
border-color: var(--border-normal);
.icon-empty {
display: block;
}
}
}
.key {
font-family: var(--family-monospace);
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<v-drawer
:title="translationString ? t('edit_translation_string') : t('create_translation_string')"
icon="translate"
:model-value="modelValue"
@update:modelValue="closeDialog"
@cancel="closeDialog"
>
<template #actions>
<v-dialog v-if="translationString" v-model="confirmDelete" @esc="confirmDelete = false">
<template #activator="{ on }">
<v-button class="delete-action" rounded icon secondary @click="on">
<v-icon name="delete" />
</v-button>
</template>
<v-card>
<v-card-title>{{ t('delete_translation_string_copy', { key: values.key }) }}</v-card-title>
<v-card-actions>
<v-button secondary @click="confirmDelete = false">
{{ t('cancel') }}
</v-button>
<v-button kind="danger" :loading="updating" @click="deleteCurrentTranslationString">
{{ t('delete_label') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button
v-tooltip.bottom="t('save')"
:loading="updating"
:disabled="!values.key || !values.translations || isEqual(values, initialValues)"
icon
rounded
@click="saveNewTranslationString"
>
<v-icon name="check" />
</v-button>
</template>
<div class="drawer-content">
<v-form v-model="formValues" :autofocus="!translationString" :initial-values="initialValues" :fields="fields" />
</div>
</v-drawer>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { isEqual } from 'lodash';
import { Field, DeepPartial } from '@directus/shared/types';
import { useTranslationStrings, TranslationString } from '@/composables/use-translation-strings';
interface Props {
modelValue: boolean;
translationString?: TranslationString | null;
}
const props = withDefaults(defineProps<Props>(), { modelValue: false, translationString: () => null });
const emit = defineEmits(['update:modelValue', 'savedKey']);
const { t } = useI18n();
const confirmDelete = ref<boolean>(false);
const values = ref<TranslationString>({
key: null,
translations: null,
});
const formValues = computed<TranslationString>({
get() {
return values.value;
},
set(val) {
values.value.key = val.key;
if (!val.translations) {
values.value.translations = null;
return;
}
// make sure translations are unique, and new ones will rewrite existing ones
values.value.translations = [...new Map(val.translations.map((item) => [item.language, item])).values()];
},
});
const initialValues = ref<TranslationString>({
key: null,
translations: null,
});
const fields = computed<DeepPartial<Field>[]>(() => {
return [
{
field: 'key',
name: '$t:key',
type: 'string',
meta: {
interface: 'input',
width: 'full',
required: true,
options: {
placeholder: '$t:translation_string_key_placeholder',
font: 'monospace',
dbSafe: true,
},
},
},
{
field: 'translations',
name: '$t:translations',
type: 'json',
meta: {
interface: 'list',
width: 'full',
required: true,
options: {
placeholder: '$t:translation_string_translations_placeholder',
template: '{{ language }} {{ translation }}',
fields: [
{
field: 'language',
name: '$t:language',
type: 'string',
meta: {
interface: 'system-language',
width: 'half',
display: 'formatted-value',
display_options: {
font: 'monospace',
color: 'var(--primary)',
background: 'var(--primary-alt)',
},
},
},
{
field: 'translation',
name: '$t:translation',
type: 'string',
meta: {
interface: 'input',
width: 'half',
options: {
placeholder: '$t:field_options.directus_collections.translation_placeholder',
},
},
},
],
},
},
},
];
});
const { translationStrings, updating, update } = useTranslationStrings();
watch(
() => props.translationString,
(newVal: TranslationString) => {
values.value.key = newVal?.key ?? null;
values.value.translations = newVal?.translations ?? null;
initialValues.value.key = newVal?.key ?? null;
initialValues.value.translations = newVal?.translations ?? null;
},
{ immediate: true }
);
function closeDialog() {
values.value.key = null;
values.value.translations = null;
initialValues.value.key = null;
initialValues.value.translations = null;
emit('update:modelValue', false);
}
async function saveNewTranslationString() {
const newTranslationStrings = translationStrings.value ? [...translationStrings.value, values.value] : [values.value];
try {
await update(newTranslationStrings);
emit('savedKey', values.value.key);
closeDialog();
} catch {
// Update shows unexpected error dialog
}
}
async function deleteCurrentTranslationString() {
const newTranslationStrings = translationStrings.value
? translationStrings.value.filter((val) => val.key !== values.value.key)
: [];
try {
await update(newTranslationStrings);
confirmDelete.value = false;
closeDialog();
} catch {
// Update shows unexpected error dialog
}
}
</script>
<style lang="scss" scoped>
.drawer-content {
padding: var(--content-padding);
padding-bottom: var(--content-padding-bottom);
}
.v-button.delete-action {
--v-button-background-color-hover: var(--danger);
--v-button-color-hover: var(--foreground-inverted);
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<ValueNull v-if="!displayedTranslation && !hideDisplayText" />
<div v-else class="translation-strings-display">
<span v-if="!hideDisplayText" class="translation-display-text">{{ displayedTranslation.translation }}</span>
<v-menu class="menu" show-arrow :disabled="!translations || translations.length === 0">
<template #activator="{ toggle, deactivate, active }">
<v-icon
v-tooltip.bottom="translations && translations.length === 0 && t('no_translations')"
:small="!hideDisplayText"
class="icon"
:class="{ active }"
name="info"
tabindex="-1"
@click.stop="toggle"
@blur="deactivate"
></v-icon>
</template>
<v-list class="translations">
<v-list-item v-for="item in translations" :key="item.language">
<v-list-item-content>
<div class="header">
<div class="lang">
<v-icon name="translate" small />
{{ item.language }}
</div>
</div>
<ValueNull v-if="!item.translation" />
<div v-else class="translation-item-text">{{ item.translation }}</div>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/stores';
import ValueNull from '@/views/private/components/value-null';
import { TranslationString } from '@/composables/use-translation-strings';
interface Props {
translations?: TranslationString['translations'];
hideDisplayText?: boolean;
}
const props = withDefaults(defineProps<Props>(), { translations: () => null, hideDisplayText: false });
const { t } = useI18n();
const { currentUser } = useUserStore();
const displayedTranslation = computed(() => {
if (!props.translations || props.translations.length === 0) return null;
if (currentUser && 'id' in currentUser) {
return props.translations.find((val) => val.language === currentUser.language) ?? props.translations[0];
}
return props.translations[0];
});
</script>
<style lang="scss" scoped>
.v-list {
width: 300px;
}
.translation-strings-display {
display: flex;
align-items: center;
.icon {
color: var(--foreground-subdued);
opacity: 0;
transition: opacity var(--fast) var(--transition);
}
&:hover .icon,
.icon.active {
opacity: 1;
}
}
.translation-display-text {
margin-right: 4px;
padding: 2px 0;
}
.translation-item-text {
padding-top: 2px;
}
.translation-display-text,
.translation-item-text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.header {
display: flex;
gap: 20px;
align-items: center;
justify-content: space-between;
color: var(--foreground-subdued);
font-size: 12px;
.lang {
font-weight: 600;
}
.v-icon {
margin-right: 4px;
}
.v-progress-linear {
flex: 1;
width: unset;
max-width: 100px;
border-radius: 4px;
}
}
.v-list-item-content {
padding-top: 4px;
padding-bottom: 2px;
}
.v-list-item:not(:first-child) {
.header {
padding-top: 8px;
border-top: var(--border-width) solid var(--border-subdued);
}
}
</style>

View File

@@ -44,4 +44,5 @@ export type Settings = {
basemaps: any[] | null;
mapbox_key: string | null;
module_bar: (SettingsModuleBarLink | SettingsModuleBarModule)[];
translation_strings: Record<string, any>[];
};