mirror of
https://github.com/directus/directus.git
synced 2026-02-01 01:45:27 -05:00
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
153
app/src/interfaces/translations/language-select.vue
Normal file
153
app/src/interfaces/translations/language-select.vue
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user