Add new translations interface (#7727)

* added v-select and button to start sidebyside view

* v-chip next to field name on translations

* v-chip color changed

* add baisc logic

* finish inner workings of translation interface

* finish design

* clean up code

* remove unused prop

* small tweaks

* finish translation interface

* fix lang icon

* tweak styling

* Use v-model over separate bind+event

* Tweak margin definition

* Add class to field-name to prevent span confusion

* Rename classes to match var names

* Add limit -1, remove commented code

* Tweak toggle tooltip wording

* Add hover state to v-icons

* Use self-closing elements

* Remove unused imports

* Rename newVal->sideBySideEnabled

* Use filter + length instead of reducer

* Fix param typo

* Move dividers into main translations component

* Base initial language on fetched languages array

* Move styling to language-select, simplify component

* Don't rely on deep styling

* Tweak interactive state of chip

* Use existing form-grid for side-by-side layoutin

* Only fetch preview values when we dont have them yet

* Improve stability of edited status

* Fix hover state of v-icon

Co-authored-by: Nitwel <nitwel@arcor.de>
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Jay Cammarano
2021-09-15 13:03:08 -04:00
committed by GitHub
parent b536905406
commit e243a33cd9
14 changed files with 732 additions and 438 deletions

View File

@@ -104,7 +104,7 @@ body {
border: var(--border-width) solid var(--v-chip-background-color);
border-radius: 16px;
&:hover {
&.clickable:hover {
color: var(--v-chip-color-hover);
background-color: var(--v-chip-background-color-hover);
border-color: var(--v-chip-background-color-hover);
@@ -120,7 +120,7 @@ body {
background-color: var(--v-chip-background-color);
border-color: var(--v-chip-background-color);
&:hover {
&.clickable:hover {
color: var(--v-chip-color);
background-color: var(--v-chip-background-color);
border-color: var(--v-chip-background-color);

View File

@@ -13,7 +13,7 @@
class="v-field-select"
>
<template #item="{ element }">
<v-chip v-tooltip="element.field" class="field draggable" @click="removeField(element.field)">
<v-chip v-tooltip="element.field" clickable class="field draggable" @click="removeField(element.field)">
{{ element.name }}
</v-chip>
</template>

View File

@@ -6,11 +6,12 @@
:value="field.field"
@update:model-value="$emit('toggle-batch', field)"
/>
<span v-tooltip="edited ? t('edited') : null" @click="toggle">
<span v-tooltip="edited ? t('edited') : null" class="field-name" @click="toggle">
{{ field.name }}
<v-icon v-if="field.meta?.required === true" class="required" sup name="star" />
<v-icon v-if="!disabled" class="ctx-arrow" :class="{ active }" name="arrow_drop_down" />
</span>
<v-chip v-if="badge" x-small>{{ badge }}</v-chip>
</div>
</template>
@@ -53,6 +54,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
badge: {
type: String,
default: null,
},
},
emits: ['toggle-batch'],
setup() {
@@ -79,6 +84,11 @@ export default defineComponent({
margin-right: 4px;
}
.v-chip {
margin: 0;
margin-left: 8px;
}
.required {
--v-icon-color: var(--primary);
@@ -88,7 +98,7 @@ export default defineComponent({
.ctx-arrow {
position: absolute;
top: -3px;
right: -20px;
right: -24px;
color: var(--foreground-subdued);
opacity: 0;
transition: opacity var(--fast) var(--transition);
@@ -118,7 +128,7 @@ export default defineComponent({
pointer-events: none;
}
> span {
.field-name {
margin-left: -16px;
padding-left: 16px;
}

View File

@@ -11,6 +11,7 @@
:batch-active="batchActive"
:edited="isEdited"
:has-error="!!validationError"
:badge="badge"
@toggle-batch="$emit('toggle-batch', $event)"
/>
</template>
@@ -111,6 +112,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
badge: {
type: String,
default: null,
},
},
emits: ['toggle-batch', 'unset', 'update:modelValue'],
setup(props, { emit }) {

View File

@@ -52,6 +52,7 @@
:primary-key="primaryKey"
:loading="loading"
:validation-error="validationErrors.find((err) => err.field === field.field)"
:badge="badge"
@update:model-value="setValue(field, $event)"
@unset="unsetValue(field)"
@toggle-batch="toggleBatchField(field)"
@@ -124,6 +125,10 @@ export default defineComponent({
type: Number,
default: null,
},
badge: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {

View File

@@ -1,14 +1,18 @@
<template>
<div
class="v-progress-linear"
:class="{
absolute,
bottom,
fixed,
indeterminate,
rounded,
top,
}"
:class="[
{
absolute,
bottom,
fixed,
indeterminate,
rounded,
top,
colorful,
},
color,
]"
@animationiteration="$emit('animationiteration')"
>
<div
@@ -22,7 +26,7 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { computed, defineComponent } from 'vue';
export default defineComponent({
props: {
@@ -54,8 +58,21 @@ export default defineComponent({
type: Number,
default: 0,
},
colorful: {
type: Boolean,
default: false,
},
},
emits: ['animationiteration'],
setup(props) {
const color = computed(() => {
if (props.value <= 33) return 'danger';
if (props.value <= 66) return 'warning';
return 'success';
});
return { color };
},
});
</script>
@@ -116,6 +133,20 @@ body {
&.top {
top: 0;
}
&.colorful {
&.danger .inner {
background-color: var(--danger);
}
&.warning .inner {
background-color: var(--warning);
}
&.success .inner {
background-color: var(--success);
}
}
}
@keyframes indeterminate {

View File

@@ -23,7 +23,10 @@
@click="toggle"
>
<template v-if="$slots.prepend" #prepend><slot name="prepend" /></template>
<template #append><v-icon name="expand_more" :class="{ active }" /></template>
<template #append>
<v-icon name="expand_more" :class="{ active }" />
<slot name="append" />
</template>
</v-input>
</template>

View File

@@ -18,6 +18,7 @@
:disabled="disabled"
small
label
clickable
@click="toggleTag(preset)"
>
{{ preset }}
@@ -32,6 +33,7 @@
class="tag"
small
label
clickable
@click="removeTag(val)"
>
{{ val }}

View File

@@ -0,0 +1,153 @@
<template>
<v-menu attached class="language-select" :class="{ secondary }">
<template #activator="{ toggle, active }">
<button class="toggle" @click="toggle">
<v-icon class="translate" name="translate" />
<span class="display-value">{{ displayValue }}</span>
<v-icon name="expand_more" :class="{ active }" />
<span class="append-slot"><slot name="append" /></span>
</button>
</template>
<v-list>
<v-list-item v-for="(item, index) in items" :key="index" @click="$emit('update:modelValue', item.value)">
<div class="start">
<div class="dot" :class="{ show: item.edited }"></div>
{{ item.text }}
</div>
<div class="end">
<v-progress-linear
v-tooltip="`${Math.round((item.current / item.max) * 100)}%`"
:value="item.progress"
colorful
/>
</div>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
export default defineComponent({
components: {},
props: {
modelValue: {
type: String,
default: null,
},
items: {
type: Array as PropType<Record<string, any>[]>,
default: () => [],
},
secondary: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props) {
const displayValue = computed(() => {
const item = props.items.find((item) => item.value === props.modelValue);
return item?.text ?? props.modelValue;
});
return { displayValue };
},
});
</script>
<style lang="scss" scoped>
.toggle {
--v-icon-color: var(--primary);
--v-icon-color-hover: var(--primary-150);
display: flex;
align-items: center;
width: 100%;
height: var(--input-height);
padding: var(--input-padding);
color: var(--primary);
text-align: left;
background-color: var(--primary-alt);
border-radius: var(--border-radius);
.display-value {
flex-grow: 1;
margin-left: 8px;
}
.append-slot:not(:empty) {
margin-left: 8px;
}
}
.v-input .input {
color: var(--primary);
background-color: var(--primary-alt);
border: 0px;
}
.v-icon {
margin-left: 6px;
}
.secondary {
.toggle {
--v-icon-color: var(--blue);
--v-icon-color-hover: var(--blue-150);
color: var(--blue);
background-color: var(--blue-alt);
}
}
.v-list {
.v-list-item {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
white-space: nowrap;
cursor: pointer;
.start {
display: flex;
flex: 1;
align-items: center;
}
.end {
display: flex;
flex-grow: 1;
gap: 10px;
align-items: center;
justify-content: flex-end;
color: var(--foreground-subdued);
}
&:hover {
background-color: var(--background-normal);
}
.dot {
width: 8px;
height: 100%;
&.show::before {
display: block;
width: 4px;
height: 4px;
background-color: var(--foreground-subdued);
border-radius: 2px;
content: '';
}
}
.v-progress-linear {
max-width: 100px;
}
}
}
</style>

View File

@@ -4,29 +4,8 @@
</v-notice>
<div v-else class="form-grid">
<div class="field half">
<p class="type-label">{{ t('language_display_template') }}</p>
<v-field-template
v-model="languageTemplate"
:collection="languageCollection"
:depth="2"
:placeholder="
languageCollectionInfo && languageCollectionInfo.meta && languageCollectionInfo.meta.display_template
"
/>
</div>
<div class="field half">
<p class="type-label">{{ t('translations_display_template') }}</p>
<v-field-template
v-model="translationsTemplate"
:collection="translationsCollection"
:depth="2"
:placeholder="
translationsCollectionInfo &&
translationsCollectionInfo.meta &&
translationsCollectionInfo.meta.display_template
"
/>
<p class="type-label">{{ t('interfaces.translations.language_field') }}</p>
<v-select v-model="languageField" :items="languageCollectionFields" item-text="name" item-value="field" />
</div>
</div>
</template>
@@ -36,7 +15,7 @@ import { useI18n } from 'vue-i18n';
import { Relation } from '@/types';
import { Field } from '@directus/shared/types';
import { defineComponent, PropType, computed } from 'vue';
import { useCollectionsStore } from '@/stores/';
import { useFieldsStore } from '@/stores/';
export default defineComponent({
props: {
@@ -61,28 +40,16 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const collectionsStore = useCollectionsStore();
const fieldsStore = useFieldsStore();
const translationsTemplate = computed({
const languageField = computed({
get() {
return props.value?.translationsTemplate;
return props.value?.languageField;
},
set(newTemplate: string) {
emit('input', {
...(props.value || {}),
translationsTemplate: newTemplate,
});
},
});
const languageTemplate = computed({
get() {
return props.value?.languageTemplate;
},
set(newTemplate: string) {
emit('input', {
...(props.value || {}),
languageTemplate: newTemplate,
languageField: newTemplate,
});
},
});
@@ -109,27 +76,18 @@ export default defineComponent({
);
});
const translationsCollection = computed(() => translationsRelation.value?.collection ?? null);
const languageCollection = computed(() => languageRelation.value?.related_collection ?? null);
const translationsCollectionInfo = computed(() => {
if (!translationsCollection.value) return null;
return collectionsStore.getCollection(translationsCollection.value);
});
const languageCollectionInfo = computed(() => {
if (!languageCollection.value) return null;
return collectionsStore.getCollection(languageCollection.value);
const languageCollectionFields = computed(() => {
if (!languageCollection.value) return [];
return fieldsStore.getFieldsForCollection(languageCollection.value);
});
return {
t,
languageTemplate,
translationsTemplate,
translationsCollection,
translationsCollectionInfo,
languageField,
languageCollection,
languageCollectionInfo,
languageCollectionFields,
};
},
});

View File

@@ -1,54 +1,66 @@
<template>
<div v-if="languagesLoading || previewLoading">
<v-skeleton-loader v-for="n in 5" :key="n" />
</div>
<v-list v-else class="translations">
<v-list-item
v-for="(languageItem, i) in languages"
:key="languageItem[languagesPrimaryKeyField]"
clickable
class="language-row"
block
@click="startEditing(languageItem[languagesPrimaryKeyField])"
>
<v-icon class="translate" name="translate" left />
<render-template :template="internalLanguageTemplate" :collection="languagesCollection" :item="languageItem" />
<render-template
class="preview"
:template="internalTranslationsTemplate"
:collection="translationsCollection"
:item="previewItems[i]"
<div class="translations" :class="{ split: splitViewEnabled }">
<div class="primary" :class="splitViewEnabled ? 'half' : 'full'">
<language-select v-model="firstLang" :items="languageOptions">
<template #append>
<v-icon
v-if="splitViewAvailable && !splitViewEnabled"
v-tooltip="t('interfaces.translations.toggle_split_view')"
name="flip"
clickable
@click.stop="splitView = true"
/>
</template>
</language-select>
<v-form
:loading="valuesLoading"
:fields="fields"
:model-value="firstItem"
:initial-values="firstItemInitial"
:badge="languageOptions.find((lang) => lang.value === firstLang)?.text"
@update:modelValue="updateValue($event, firstLang)"
/>
<div class="spacer" />
</v-list-item>
<drawer-item
v-if="editing"
active
:collection="translationsCollection"
:primary-key="editing"
:edits="edits"
:circular-field="translationsRelation.field"
@input="stageEdits"
@update:active="cancelEdit"
/>
</v-list>
<v-divider />
</div>
<div v-if="splitViewEnabled" class="secondary" :class="splitViewEnabled ? 'half' : 'full'">
<language-select v-model="secondLang" :items="languageOptions" secondary>
<template #append>
<v-icon
v-tooltip="t('interfaces.translations.toggle_split_view')"
name="close"
clickable
@click.stop="splitView = !splitView"
/>
</template>
</language-select>
<v-form
:loading="valuesLoading"
:initial-values="secondItemInitial"
:fields="fields"
:badge="languageOptions.find((lang) => lang.value === secondLang)?.text"
:model-value="secondItem"
@update:modelValue="updateValue($event, secondLang)"
/>
<v-divider />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref, watch } from 'vue';
import { useRelationsStore, useFieldsStore } from '@/stores/';
import LanguageSelect from './language-select.vue';
import { computed, defineComponent, PropType, Ref, ref, toRefs, watch, unref } from 'vue';
import useCollection from '@/composables/use-collection';
import { useFieldsStore, useRelationsStore } from '@/stores/';
import { useI18n } from 'vue-i18n';
import api from '@/api';
import { Relation } from '@/types';
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
import DrawerItem from '@/views/private/components/drawer-item/drawer-item.vue';
import { useCollection } from '@/composables/use-collection';
import { unexpectedError } from '@/utils/unexpected-error';
import { isPlainObject } from 'lodash';
import { cloneDeep, isEqual, assign } from 'lodash';
import { notEmpty } from '@/utils/is-empty';
import { useWindowSize } from '@/composables/use-window-size';
export default defineComponent({
components: { DrawerItem },
components: { LanguageSelect },
props: {
collection: {
type: String,
@@ -62,23 +74,37 @@ export default defineComponent({
type: String,
required: true,
},
languageTemplate: {
type: String,
default: null,
},
translationsTemplate: {
languageField: {
type: String,
default: null,
},
value: {
type: Array as PropType<(string | number | Record<string, any>)[]>,
default: () => [],
type: Array as PropType<(string | number | Record<string, any>)[] | null>,
default: null,
},
},
emits: ['input'],
setup(props, { emit }) {
const { collection } = toRefs(props);
const fieldsStore = useFieldsStore();
const relationsStore = useRelationsStore();
const { t } = useI18n();
const { width } = useWindowSize();
const splitView = ref(false);
const firstLang = ref<string | number>();
const secondLang = ref<string | number>();
const { info: collectionInfo } = useCollection(collection);
watch(splitView, (splitViewEnabled) => {
const lang = languageOptions.value;
if (splitViewEnabled && secondLang.value === firstLang.value) {
secondLang.value = lang[0].value === firstLang.value ? lang[1].value : lang[0].value;
}
});
const {
relationsForField,
@@ -91,29 +117,57 @@ export default defineComponent({
translationsLanguageField,
} = useRelations();
const { languages, loading: languagesLoading, template: internalLanguageTemplate } = useLanguages();
const { startEditing, editing, edits, stageEdits, cancelEdit } = useEdits();
const { previewItems, template: internalTranslationsTemplate, loading: previewLoading } = usePreview();
const { languageOptions, loading: languagesLoading } = useLanguages();
const {
items,
firstItem,
loading: valuesLoading,
updateValue,
secondItem,
firstItemInitial,
secondItemInitial,
} = useEdits();
const fields = computed(() => {
if (translationsCollection.value === null) return [];
return fieldsStore.getFieldsForCollection(translationsCollection.value);
});
const splitViewAvailable = computed(() => {
return width.value > 960;
});
const splitViewEnabled = computed(() => {
return splitViewAvailable.value && splitView.value;
});
return {
collectionInfo,
splitView,
firstLang,
secondLang,
t,
languageOptions,
fields,
relationsForField,
translationsRelation,
translationsCollection,
translationsPrimaryKeyField,
languagesRelation,
languages,
internalLanguageTemplate,
internalTranslationsTemplate,
languagesCollection,
languagesPrimaryKeyField,
languagesLoading,
startEditing,
translationsLanguageField,
editing,
stageEdits,
cancelEdit,
edits,
previewItems,
previewLoading,
items,
firstItem,
secondItem,
updateValue,
relationsStore,
firstItemInitial,
secondItemInitial,
splitViewAvailable,
splitViewEnabled,
languagesLoading,
valuesLoading,
};
function useRelations() {
@@ -174,40 +228,84 @@ export default defineComponent({
}
function useLanguages() {
const languages = ref<Record<string, any>[]>();
const languages = ref<Record<string, any>[]>([]);
const loading = ref(false);
const error = ref<any>(null);
const { info: languagesCollectionInfo } = useCollection(languagesCollection);
const template = computed(() => {
if (!languagesPrimaryKeyField.value) return '';
return (
props.languageTemplate ||
languagesCollectionInfo.value?.meta?.display_template ||
`{{ ${languagesPrimaryKeyField.value} }}`
);
});
watch(languagesCollection, fetchLanguages, { immediate: true });
return { languages, loading, error, template };
const languageOptions = computed(() => {
const langField = translationsLanguageField.value;
if (langField === null) return [];
const writableFields = fields.value.filter(
(field) => field.type !== 'alias' && field.meta?.hidden === false && field.meta.readonly === false
);
const totalFields = writableFields.length;
return languages.value.map((language) => {
if (languagesPrimaryKeyField.value === null) return language;
const langCode = language[languagesPrimaryKeyField.value];
const initialValue = items.value.find((item) => item[langField] === langCode) ?? {};
const edits = props.value?.find((val) => typeof val === 'object' && val[langField] === langCode) as
| Record<string, any>
| undefined;
const item = { ...initialValue, ...(edits ?? {}) };
const filledFields = writableFields.filter((field) => {
return field.field in item && notEmpty(item[field.field]);
}).length;
return {
text: language[props.languageField ?? languagesPrimaryKeyField.value],
value: langCode,
edited: edits !== undefined,
progress: (filledFields / totalFields) * 100,
max: totalFields,
current: filledFields,
};
});
});
return { languageOptions, loading, error };
async function fetchLanguages() {
if (!languagesCollection.value || !languagesPrimaryKeyField.value) return;
const fields = getFieldsFromTemplate(template.value);
const fields = new Set<string>();
if (fields.includes(languagesPrimaryKeyField.value) === false) {
fields.push(languagesPrimaryKeyField.value);
if (props.languageField !== null) {
fields.add(props.languageField);
}
fields.add(languagesPrimaryKeyField.value);
loading.value = true;
try {
const response = await api.get(`/items/${languagesCollection.value}`, { params: { fields, limit: -1 } });
const response = await api.get(`/items/${languagesCollection.value}`, {
params: {
fields: Array.from(fields),
limit: -1,
sort: props.languageField ?? languagesPrimaryKeyField.value,
},
});
languages.value = response.data.data;
if (!firstLang.value) {
firstLang.value = response.data.data?.[0]?.[languagesPrimaryKeyField.value];
}
if (!secondLang.value) {
secondLang.value = response.data.data?.[1]?.[languagesPrimaryKeyField.value];
}
} catch (err: any) {
unexpectedError(err);
} finally {
@@ -217,234 +315,174 @@ export default defineComponent({
}
function useEdits() {
const keyMap = ref<Record<string, string | number>[]>();
const loading = ref(false);
const error = ref<any>(null);
const editing = ref<boolean | string | number>(false);
const edits = ref<Record<string, any>>();
const existingPrimaryKeys = computed(() => {
const pkField = translationsPrimaryKeyField.value;
if (!pkField) return [];
return (props.value || [])
.map((value) => {
if (typeof value === 'string' || typeof value === 'number') return value;
return value[pkField];
})
.filter((key) => key);
});
watch(() => props.value, fetchKeyMap, { immediate: true });
return { startEditing, editing, edits, stageEdits, cancelEdit };
function startEditing(language: string | number) {
if (!translationsLanguageField.value || !translationsPrimaryKeyField.value) return;
edits.value = {
[translationsLanguageField.value]: language,
};
const existingEdits = (props.value || []).find((val) => {
if (typeof val === 'string' || typeof val === 'number') return false;
return val[translationsLanguageField.value!] === language;
});
if (existingEdits) {
edits.value = {
...edits.value,
...(existingEdits as Record<string, any>),
};
}
const primaryKey =
keyMap.value?.find((record) => record[translationsLanguageField.value!] === language)?.[
translationsPrimaryKeyField.value
] || '+';
if (primaryKey !== '+') {
edits.value = {
...edits.value,
[translationsPrimaryKeyField.value]: primaryKey,
};
}
editing.value = primaryKey;
}
async function fetchKeyMap() {
if (!props.value) return;
if (keyMap.value) return;
if (!existingPrimaryKeys.value?.length) return;
const pkField = translationsPrimaryKeyField.value;
if (!pkField) return;
const collection = translationsRelation.value?.collection;
if (!collection) return;
const fields = [pkField, translationsLanguageField.value];
loading.value = true;
try {
const response = await api.get(`/items/${collection}`, {
params: {
fields,
filter: {
[pkField]: {
_in: existingPrimaryKeys.value,
},
},
limit: -1,
},
});
keyMap.value = response.data.data;
} catch (err: any) {
error.value = err;
} finally {
loading.value = false;
}
}
function stageEdits(edits: any) {
if (!translationsLanguageField.value) return;
const pkField = translationsPrimaryKeyField.value;
if (!pkField) return;
const editedLanguage = edits[translationsLanguageField.value];
const languageAlreadyEdited = !!(props.value || []).find((val) => {
if (typeof val === 'string' || typeof val === 'number') return false;
return val[translationsLanguageField.value!] === editedLanguage;
});
if (languageAlreadyEdited === true) {
emit(
'input',
props.value.map((val) => {
if (typeof val === 'string' || typeof val === 'number') return val;
if (val[translationsLanguageField.value!] === editedLanguage) {
return edits;
}
return val;
})
);
} else {
if (editing.value === '+') {
emit('input', [...(props.value || []), edits]);
} else {
emit(
'input',
props.value.map((val) => {
if (typeof val === 'string' || typeof val === 'number') {
if (val === editing.value) return edits;
} else {
if (val[pkField] === editing.value) return edits;
}
return val;
})
);
}
}
editing.value = false;
}
function cancelEdit() {
edits.value = {};
editing.value = false;
}
}
function usePreview() {
const items = ref<Record<string, any>[]>([]);
const loading = ref(false);
const error = ref(null);
const previewItems = ref<Record<string, any>[]>([]);
const { info: translationsCollectionInfo } = useCollection(translationsCollection);
const firstItem = computed(() => getEditedValue(firstLang));
const secondItem = computed(() => getEditedValue(secondLang));
const template = computed(() => {
if (!translationsPrimaryKeyField.value) return '';
const firstItemInitial = computed<Record<string, any>>(() => getExistingValue(firstLang));
const secondItemInitial = computed<Record<string, any>>(() => getExistingValue(secondLang));
watch(
() => props.value,
(newVal, oldVal) => {
if (
newVal &&
newVal !== oldVal &&
newVal?.every((item) => typeof item === 'string' || typeof item === 'number')
) {
loadItems();
}
if (newVal === null || newVal.length === 0) {
items.value = [];
}
},
{ immediate: true }
);
return { items, firstItem, updateValue, secondItem, firstItemInitial, secondItemInitial, loading, error };
function getExistingValue(langRef: string | number | undefined | Ref<string | number | undefined>) {
const lang = unref(langRef);
const langField = translationsLanguageField.value;
if (langField === null) return {};
return (items.value.find((item) => item[langField] === lang) as Record<string, any>) ?? {};
}
function getEditedValue(langRef: string | number | undefined | Ref<string | number | undefined>) {
const lang = unref(langRef);
const langField = translationsLanguageField.value;
if (langField === null) return {};
return (
props.translationsTemplate ||
translationsCollectionInfo.value?.meta?.display_template ||
`{{ ${translationsPrimaryKeyField.value} }}`
(props.value?.find((item) => typeof item === 'object' && item[langField] === lang) as Record<string, any>) ??
{}
);
});
}
watch(() => props.value, fetchPreviews, { immediate: true });
watch(languages, fetchPreviews, { immediate: true });
async function loadItems() {
const pkField = translationsPrimaryKeyField.value;
return { loading, error, previewItems, fetchPreviews, template };
async function fetchPreviews() {
if (!translationsRelation.value || !languagesRelation.value || !languages.value) return;
if (props.primaryKey === '+') return;
if (pkField === null || !props.value || props.value.length === 0) return;
loading.value = true;
try {
const fields = getFieldsFromTemplate(template.value);
if (fields.includes(languagesRelation.value.field) === false) {
fields.push(languagesRelation.value.field);
}
const existing = await api.get(`/items/${translationsCollection.value}`, {
const response = await api.get(`/items/${translationsCollection.value}`, {
params: {
fields,
fields: '*',
limit: -1,
filter: {
[translationsRelation.value.field]: {
_eq: props.primaryKey,
[pkField]: {
_in: props.value,
},
},
limit: -1,
},
});
previewItems.value = languages.value.map((language) => {
const pkField = languagesPrimaryKeyField.value;
if (!pkField) return;
const existingEdit =
props.value && Array.isArray(props.value)
? (props.value.find(
(edit) =>
isPlainObject(edit) &&
(edit as Record<string, any>)[languagesRelation.value!.field] === language[pkField]
) as Record<string, any>)
: {};
return {
...(existing.data.data?.find(
(item: Record<string, any>) => item[languagesRelation.value!.field] === language[pkField]
) ?? {}),
...existingEdit,
};
});
} catch (err: any) {
items.value = response.data.data;
} catch (err) {
error.value = err;
previewItems.value = [];
unexpectedError(err);
} finally {
loading.value = false;
}
}
function updateValue(edits: Record<string, any>, lang: string) {
const pkField = translationsPrimaryKeyField.value;
const langField = translationsLanguageField.value;
const existing = getExistingValue(lang);
const values = assign({}, existing, edits);
if (pkField === null || langField === null) return;
let copyValue = cloneDeep(props.value ?? []);
if (pkField in values === false) {
const newIndex = copyValue.findIndex((item) => typeof item === 'object' && item[langField] === lang);
if (newIndex !== -1) {
if (Object.keys(values).length === 1 && langField in values) {
copyValue.splice(newIndex, 1);
} else {
copyValue[newIndex] = values;
}
} else {
copyValue.push({
...values,
[langField]: lang,
});
}
} else {
const initialValues = items.value.find((item) => item[langField] === lang);
copyValue = copyValue.map((item) => {
if (typeof item === 'number' || typeof item === 'string') {
if (values[pkField] === item) {
return values;
} else {
return item;
}
} else {
if (values[pkField] === item[pkField]) {
if (isEqual(initialValues, { ...initialValues, ...values })) {
return values[pkField];
} else {
return values;
}
} else {
return item;
}
}
});
}
emit('input', copyValue);
}
}
},
});
</script>
<style scoped>
.preview {
color: var(--foreground-subdued);
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.translations {
@include form-grid;
.v-form {
--form-vertical-gap: 32px;
--v-chip-color: var(--primary);
--v-chip-background-color: var(--primary-alt);
margin-top: 32px;
}
.v-divider {
margin-top: var(--form-vertical-gap);
}
.primary {
--v-divider-color: var(--primary-50);
}
.secondary {
--v-divider-color: var(--blue-50);
.v-form {
--primary: var(--blue);
--v-chip-color: var(--blue);
--v-chip-background-color: var(--blue-alt);
}
}
}
</style>

View File

@@ -1288,6 +1288,8 @@ interfaces:
translations:
display_template: Display Template
no_collection: No Collection
toggle_split_view: Toggle Split View
language_field: Language Field
list-o2m-tree-view:
description: Tree view for nested recursive one-to-many items
recursive_only: The tree view interface only works for recursive relationships.