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,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',
},