mirror of
https://github.com/directus/directus.git
synced 2026-02-02 00:45:10 -05:00
Relational consistency (#4093)
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -7,11 +7,12 @@
|
||||
:to="to"
|
||||
:class="{
|
||||
active,
|
||||
large,
|
||||
dense,
|
||||
link: isClickable,
|
||||
disabled,
|
||||
dashed,
|
||||
block,
|
||||
large,
|
||||
}"
|
||||
:href="href"
|
||||
:download="download"
|
||||
@@ -29,7 +30,7 @@ import { useGroupable } from '@/composables/groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
large: {
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -69,6 +70,10 @@ export default defineComponent({
|
||||
type: [String, Number],
|
||||
default: undefined,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { listeners }) {
|
||||
const component = computed<string>(() => {
|
||||
@@ -77,7 +82,7 @@ export default defineComponent({
|
||||
return 'li';
|
||||
});
|
||||
|
||||
const { active: groupActive, toggle, activate, deactivate } = useGroupable({
|
||||
useGroupable({
|
||||
value: props.value,
|
||||
});
|
||||
|
||||
@@ -90,15 +95,14 @@ export default defineComponent({
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-list-item-min-height-large: 40px;
|
||||
--v-list-item-min-height: 32px;
|
||||
--v-list-item-padding-large: 0 8px;
|
||||
--v-list-item-padding: 0 8px 0 calc(8px + var(--v-list-item-indent, 0px));
|
||||
--v-list-item-margin-large: 4px 0;
|
||||
--v-list-item-margin: 2px 0;
|
||||
--v-list-item-min-width: none;
|
||||
--v-list-item-max-width: none;
|
||||
--v-list-item-min-height: var(--v-list-item-min-height);
|
||||
--v-list-item-min-height-large: 40px;
|
||||
--v-list-item-min-height: 32px;
|
||||
--v-list-item-max-height: auto;
|
||||
--v-list-item-border-radius: var(--border-radius);
|
||||
--v-list-item-color: var(--v-list-color, var(--foreground-normal));
|
||||
@@ -150,12 +154,12 @@ body {
|
||||
transition-property: background-color, color;
|
||||
user-select: none;
|
||||
|
||||
&:not(.disabled):not(.dense):hover {
|
||||
&:not(.disabled):not(.dense):not(.block):hover {
|
||||
color: var(--v-list-item-color-hover);
|
||||
background-color: var(--v-list-item-background-color-hover);
|
||||
}
|
||||
|
||||
&:not(.disabled):not(.dense):active {
|
||||
&:not(.disabled):not(.dense):not(.block):active {
|
||||
color: var(--v-list-item-color-active);
|
||||
background-color: var(--v-list-item-background-color-active);
|
||||
}
|
||||
@@ -185,13 +189,54 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.v-list,
|
||||
#{$this},
|
||||
.v-list #{$this} {
|
||||
--v-list-item-min-height: var(--v-list-item-min-height);
|
||||
&.block {
|
||||
--v-list-item-min-height: 44px;
|
||||
|
||||
display: flex;
|
||||
background-color: var(--background-subdued);
|
||||
border: 2px solid var(--border-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-color var(--fast) var(--transition);
|
||||
|
||||
.v-icon {
|
||||
color: var(--foreground-subdued);
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-subdued);
|
||||
border: 2px solid var(--border-normal);
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&.dense {
|
||||
--v-list-item-min-height: 34px;
|
||||
|
||||
& + & {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.v-list.large {
|
||||
#{$this}:not(.dense) {
|
||||
--v-list-item-min-height: var(--v-list-item-min-height-large);
|
||||
|
||||
@@ -4,54 +4,58 @@
|
||||
<v-skeleton-loader v-for="n in (value || []).length" :key="n" />
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-else
|
||||
:force-fallback="true"
|
||||
:value="previewValues"
|
||||
handle=".drag-handle"
|
||||
@input="onSort"
|
||||
:set-data="hideDragImage"
|
||||
:disabled="!o2mRelation.sort_field"
|
||||
>
|
||||
<template v-for="item of previewValues">
|
||||
<div
|
||||
:key="item.$index"
|
||||
v-if="allowedCollections.includes(item[anyRelation.one_collection_field])"
|
||||
class="m2a-row"
|
||||
@click="editExisting((value || [])[item.$index])"
|
||||
>
|
||||
<v-icon class="drag-handle" name="drag_handle" @click.stop v-if="o2mRelation.sort_field" />
|
||||
<span class="collection">{{ collections[item[anyRelation.one_collection_field]].name }}:</span>
|
||||
<span
|
||||
v-if="typeof item[anyRelation.many_field] === 'number' || typeof item[anyRelation.many_field] === 'string'"
|
||||
<v-list v-else>
|
||||
<draggable
|
||||
:force-fallback="true"
|
||||
:value="previewValues"
|
||||
handle=".drag-handle"
|
||||
@input="onSort"
|
||||
:set-data="hideDragImage"
|
||||
:disabled="!o2mRelation.sort_field"
|
||||
>
|
||||
<template v-for="item of previewValues">
|
||||
<v-list-item
|
||||
:key="item.$index"
|
||||
v-if="allowedCollections.includes(item[anyRelation.one_collection_field])"
|
||||
block
|
||||
@click="editExisting((value || [])[item.$index])"
|
||||
>
|
||||
{{ item[anyRelation.many_field] }}
|
||||
</span>
|
||||
<render-template
|
||||
v-else
|
||||
:collection="item[anyRelation.one_collection_field]"
|
||||
:template="templates[item[anyRelation.one_collection_field]]"
|
||||
:item="item[anyRelation.many_field]"
|
||||
/>
|
||||
<div class="spacer" />
|
||||
<v-icon class="clear-icon" name="clear" @click.stop="deselect((value || [])[item.$index])" />
|
||||
<v-icon class="launch-icon" name="launch" />
|
||||
</div>
|
||||
<v-icon class="drag-handle" left name="drag_handle" @click.stop v-if="o2mRelation.sort_field" />
|
||||
<span class="collection">{{ collections[item[anyRelation.one_collection_field]].name }}:</span>
|
||||
<span
|
||||
v-if="
|
||||
typeof item[anyRelation.many_field] === 'number' || typeof item[anyRelation.many_field] === 'string'
|
||||
"
|
||||
>
|
||||
{{ item[anyRelation.many_field] }}
|
||||
</span>
|
||||
<render-template
|
||||
v-else
|
||||
:collection="item[anyRelation.one_collection_field]"
|
||||
:template="templates[item[anyRelation.one_collection_field]]"
|
||||
:item="item[anyRelation.many_field]"
|
||||
/>
|
||||
<div class="spacer" />
|
||||
<v-icon class="clear-icon" name="clear" @click.stop="deselect((value || [])[item.$index])" />
|
||||
<v-icon class="launch-icon" name="launch" />
|
||||
</v-list-item>
|
||||
|
||||
<div v-else class="m2a-row invalid" :key="item.$index">
|
||||
<v-icon class="invalid-icon" name="warning" left />
|
||||
<span>{{ $t('invalid_item') }}</span>
|
||||
<div class="spacer" />
|
||||
<v-icon class="clear-icon" name="clear" @click.stop="deselect((value || [])[item.$index])" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<v-list-item v-else :key="item.$index" block>
|
||||
<v-icon class="invalid-icon" name="warning" left />
|
||||
<span>{{ $t('invalid_item') }}</span>
|
||||
<div class="spacer" />
|
||||
<v-icon class="clear-icon" name="clear" @click.stop="deselect((value || [])[item.$index])" />
|
||||
</v-list-item>
|
||||
</template>
|
||||
</draggable>
|
||||
</v-list>
|
||||
|
||||
<div class="buttons">
|
||||
<v-menu attached>
|
||||
<v-menu show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<v-button dashed outlined full-width @click="toggle">
|
||||
<v-button @click="toggle">
|
||||
{{ $t('create_new') }}
|
||||
<v-icon name="arrow_drop_down" right />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
@@ -67,10 +71,11 @@
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-menu attached>
|
||||
<v-menu show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<v-button dashed outlined full-width @click="toggle">
|
||||
<v-button @click="toggle" class="existing">
|
||||
{{ $t('add_existing') }}
|
||||
<v-icon name="arrow_drop_down" right />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
@@ -633,19 +638,7 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.m2a-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background-color: var(--background-subdued);
|
||||
border: 2px solid var(--border-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
|
||||
& + .m2a-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
.collection {
|
||||
margin-right: 1ch;
|
||||
color: var(--primary);
|
||||
@@ -663,19 +656,15 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-gap: var(--form-horizontal-gap);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
.existing {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
margin-right: 8px;
|
||||
cursor: grab !important;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
|
||||
@@ -3,43 +3,22 @@
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div class="many-to-many" v-else>
|
||||
<v-table
|
||||
:loading="loading"
|
||||
:items="sortedItems || items"
|
||||
:headers.sync="tableHeaders"
|
||||
show-resize
|
||||
inline
|
||||
:sort.sync="sort"
|
||||
@update:items="sortItems($event)"
|
||||
@click:row="editItem"
|
||||
:disabled="disabled"
|
||||
:show-manual-sort="relationInfo.sortField !== null"
|
||||
:manual-sort-key="relationInfo.sortField"
|
||||
>
|
||||
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
|
||||
<render-display
|
||||
:key="header.value"
|
||||
:value="get(item, header.value)"
|
||||
:display="header.field.display"
|
||||
:options="header.field.displayOptions"
|
||||
:interface="header.field.interface"
|
||||
:interface-options="header.field.interfaceOptions"
|
||||
:type="header.field.type"
|
||||
:collection="relationInfo.junctionCollection"
|
||||
:field="header.field.field"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }">
|
||||
<v-icon
|
||||
v-show="!disabled"
|
||||
name="close"
|
||||
v-tooltip="$t('deselect')"
|
||||
class="deselect"
|
||||
@click.stop="deleteItem(item)"
|
||||
/>
|
||||
</template>
|
||||
</v-table>
|
||||
<v-list>
|
||||
<draggable
|
||||
:force-fallback="true"
|
||||
:value="sortedItems || items"
|
||||
@input="sortItems($event)"
|
||||
handler=".drag-handle"
|
||||
:disabled="!relation.sort_field"
|
||||
>
|
||||
<v-list-item v-for="item in sortedItems || items" :key="item.id" block @click="editItem(item)">
|
||||
<v-icon v-if="relation.sort_field" name="drag_handle" class="drag-handle" left @click.stop="() => {}" />
|
||||
<render-template :item="item" :template="templateWithDefaults" />
|
||||
<div class="spacer" />
|
||||
<v-icon name="close" @click.stop="deleteItem(item)" />
|
||||
</v-list-item>
|
||||
</draggable>
|
||||
</v-list>
|
||||
|
||||
<div class="actions" v-if="!disabled">
|
||||
<v-button class="new" @click="editModalActive = true">{{ $t('create_new') }}</v-button>
|
||||
@@ -78,6 +57,7 @@ import { defineComponent, ref, computed, watch, PropType, toRefs } from '@vue/co
|
||||
import DrawerItem from '@/views/private/components/drawer-item';
|
||||
import DrawerCollection from '@/views/private/components/drawer-collection';
|
||||
import { get } from 'lodash';
|
||||
import Draggable from 'vuedraggable';
|
||||
|
||||
import useActions from './use-actions';
|
||||
import useRelation from './use-relation';
|
||||
@@ -85,9 +65,10 @@ import usePreview from './use-preview';
|
||||
import useEdit from './use-edit';
|
||||
import useSelection from './use-selection';
|
||||
import useSort from './use-sort';
|
||||
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
|
||||
|
||||
export default defineComponent({
|
||||
components: { DrawerItem, DrawerCollection },
|
||||
components: { DrawerItem, DrawerCollection, Draggable },
|
||||
props: {
|
||||
value: {
|
||||
type: Array as PropType<(number | string | Record<string, any>)[] | null>,
|
||||
@@ -105,9 +86,9 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
template: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
@@ -115,14 +96,16 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const { value, collection, field, fields } = toRefs(props);
|
||||
|
||||
function emitter(newVal: any[] | null) {
|
||||
emit('input', newVal);
|
||||
}
|
||||
const { value, collection, field } = toRefs(props);
|
||||
|
||||
const { junction, junctionCollection, relation, relationCollection, relationInfo } = useRelation(collection, field);
|
||||
|
||||
const templateWithDefaults = computed(
|
||||
() => props.template || junctionCollection.value.meta?.display_template || `{{${junction.value.many_primary}}}`
|
||||
);
|
||||
|
||||
const fields = computed(() => getFieldsFromTemplate(templateWithDefaults.value));
|
||||
|
||||
const { deleteItem, getUpdatedItems, getNewItems, getPrimaryKeys, getNewSelectedItems } = useActions(
|
||||
value,
|
||||
relationInfo,
|
||||
@@ -177,7 +160,12 @@ export default defineComponent({
|
||||
sort,
|
||||
sortItems,
|
||||
sortedItems,
|
||||
templateWithDefaults,
|
||||
};
|
||||
|
||||
function emitter(newVal: any[] | null) {
|
||||
emit('input', newVal);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select
|
||||
<v-field-template
|
||||
:collection="junctionCollection"
|
||||
v-model="fields"
|
||||
v-model="template"
|
||||
:inject="junctionCollectionExists ? null : { fields: newFields, collections: newCollections, relations }"
|
||||
/>
|
||||
</div>
|
||||
@@ -50,14 +50,14 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
|
||||
const fields = computed({
|
||||
const template = computed({
|
||||
get() {
|
||||
return props.value?.fields;
|
||||
return props.value?.template;
|
||||
},
|
||||
set(newFields: string) {
|
||||
set(newTemplate: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
fields: newFields,
|
||||
template: newTemplate,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -77,7 +77,7 @@ export default defineComponent({
|
||||
);
|
||||
});
|
||||
|
||||
return { fields, junctionCollection, junctionCollectionExists };
|
||||
return { template, junctionCollection, junctionCollectionExists };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,43 +3,22 @@
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div class="one-to-many" v-else>
|
||||
<v-table
|
||||
:loading="loading"
|
||||
:items="sortedItems || items"
|
||||
:headers.sync="tableHeaders"
|
||||
show-resize
|
||||
inline
|
||||
:sort.sync="sort"
|
||||
@update:items="sortItems($event)"
|
||||
@click:row="editItem"
|
||||
:disabled="disabled"
|
||||
:show-manual-sort="relation.sort_field !== null"
|
||||
:manual-sort-key="relation.sort_field"
|
||||
>
|
||||
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
|
||||
<render-display
|
||||
:key="header.value"
|
||||
:value="get(item, header.value)"
|
||||
:display="header.field.display"
|
||||
:options="header.field.displayOptions"
|
||||
:interface="header.field.interface"
|
||||
:interface-options="header.field.interfaceOptions"
|
||||
:type="header.field.type"
|
||||
:collection="relatedCollection.collection"
|
||||
:field="header.field.field"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }">
|
||||
<v-icon
|
||||
v-if="!disabled"
|
||||
name="close"
|
||||
v-tooltip="$t('deselect')"
|
||||
class="deselect"
|
||||
@click.stop="deleteItem(item)"
|
||||
/>
|
||||
</template>
|
||||
</v-table>
|
||||
<v-list>
|
||||
<draggable
|
||||
:force-fallback="true"
|
||||
:value="sortedItems"
|
||||
@input="sortItems($event)"
|
||||
handler=".drag-handle"
|
||||
:disabled="!relation.sort_field"
|
||||
>
|
||||
<v-list-item v-for="item in sortedItems" :key="item.id" block @click="editItem(item)">
|
||||
<v-icon v-if="relation.sort_field" name="drag_handle" class="drag-handle" left @click.stop="() => {}" />
|
||||
<render-template :collection="relation.many_collection" :item="item" :template="templateWithDefaults" />
|
||||
<div class="spacer" />
|
||||
<v-icon name="close" @click.stop="deleteItem(item)" />
|
||||
</v-list-item>
|
||||
</draggable>
|
||||
</v-list>
|
||||
|
||||
<div class="actions" v-if="!disabled">
|
||||
<v-button class="new" @click="currentlyEditing = '+'">{{ $t('create_new') }}</v-button>
|
||||
@@ -83,9 +62,11 @@ import { Header, Sort } from '@/components/v-table/types';
|
||||
import { isEqual, sortBy } from 'lodash';
|
||||
import { get } from 'lodash';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
|
||||
import Draggable from 'vuedraggable';
|
||||
|
||||
export default defineComponent({
|
||||
components: { DrawerItem, DrawerCollection },
|
||||
components: { DrawerItem, DrawerCollection, Draggable },
|
||||
props: {
|
||||
value: {
|
||||
type: Array as PropType<(number | string | Record<string, any>)[] | null>,
|
||||
@@ -103,9 +84,9 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
template: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
@@ -123,6 +104,11 @@ export default defineComponent({
|
||||
const { stageSelection, selectModalActive, selectionFilters } = useSelection();
|
||||
const { sort, sortItems, sortedItems } = useSort();
|
||||
|
||||
const templateWithDefaults = computed(
|
||||
() => props.template || relatedCollection.value.meta?.display_template || `{{${relation.value.many_primary}}}`
|
||||
);
|
||||
const fields = computed(() => getFieldsFromTemplate(templateWithDefaults.value));
|
||||
|
||||
return {
|
||||
relation,
|
||||
tableHeaders,
|
||||
@@ -142,8 +128,14 @@ export default defineComponent({
|
||||
sort,
|
||||
sortedItems,
|
||||
get,
|
||||
getItemFromIndex,
|
||||
templateWithDefaults,
|
||||
};
|
||||
|
||||
function getItemFromIndex(index: number) {
|
||||
return (sortedItems.value || items.value)[index];
|
||||
}
|
||||
|
||||
function getNewItems() {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
if (props.value === null) return [];
|
||||
@@ -203,7 +195,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function useSort() {
|
||||
const sort = ref<Sort>({ by: relation.value.sort_field || props.fields[0], desc: false });
|
||||
const sort = ref<Sort>({ by: relation.value.sort_field || fields.value[0], desc: false });
|
||||
|
||||
function sortItems(newItems: Record<string, any>[]) {
|
||||
if (relation.value.sort_field === null) return;
|
||||
@@ -217,7 +209,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
if (relation.value.sort_field === null || sort.value.by !== relation.value.sort_field) return null;
|
||||
if (relation.value.sort_field === null || sort.value.by !== relation.value.sort_field) return items.value;
|
||||
|
||||
const desc = sort.value.desc;
|
||||
const sorted = sortBy(items.value, [relation.value.sort_field]);
|
||||
@@ -258,14 +250,14 @@ export default defineComponent({
|
||||
loading.value = true;
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const fields = [...(props.fields.length > 0 ? props.fields : getDefaultFields())];
|
||||
const fieldsList = [...(fields.value.length > 0 ? fields.value : getDefaultFields())];
|
||||
|
||||
if (fields.includes(pkField) === false) {
|
||||
fields.push(pkField);
|
||||
if (fieldsList.includes(pkField) === false) {
|
||||
fieldsList.push(pkField);
|
||||
}
|
||||
|
||||
if (relation.value.sort_field !== null && fields.includes(relation.value.sort_field) === false)
|
||||
fields.push(relation.value.sort_field);
|
||||
if (relation.value.sort_field !== null && fieldsList.includes(relation.value.sort_field) === false)
|
||||
fieldsList.push(relation.value.sort_field);
|
||||
|
||||
try {
|
||||
const endpoint = relatedCollection.value.collection.startsWith('directus_')
|
||||
@@ -279,7 +271,7 @@ export default defineComponent({
|
||||
if (primaryKeys && primaryKeys.length > 0) {
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: fields,
|
||||
fields: fieldsList,
|
||||
[`filter[${pkField}][_in]`]: primaryKeys.join(','),
|
||||
},
|
||||
});
|
||||
@@ -316,9 +308,9 @@ export default defineComponent({
|
||||
// Seeing we don't care about saving those tableHeaders, we can reset it whenever the
|
||||
// fields prop changes (most likely when we're navigating to a different o2m context)
|
||||
watch(
|
||||
() => props.fields,
|
||||
() => fields.value,
|
||||
() => {
|
||||
tableHeaders.value = (props.fields.length > 0 ? props.fields : getDefaultFields())
|
||||
tableHeaders.value = (fields.value.length > 0 ? fields.value : getDefaultFields())
|
||||
.map((fieldKey) => {
|
||||
const field = fieldsStore.getField(relatedCollection.value.collection, fieldKey);
|
||||
|
||||
@@ -456,11 +448,11 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.existing {
|
||||
margin-left: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.deselect {
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<div class="field half-left">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select
|
||||
<v-field-template
|
||||
:collection="relatedCollection"
|
||||
v-model="fields"
|
||||
v-model="template"
|
||||
:inject="relatedCollectionExists ? null : { fields: newFields, collections: newCollections, relations }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field half-right">
|
||||
<p class="type-label">{{ $t('order') }}</p>
|
||||
<v-field-select v-model="order" :collection="relatedCollection" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -49,14 +54,26 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
|
||||
const fields = computed({
|
||||
const template = computed({
|
||||
get() {
|
||||
return props.value?.fields;
|
||||
return props.value?.template;
|
||||
},
|
||||
set(newFields: string) {
|
||||
set(newTemplate: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
fields: newFields,
|
||||
template: newTemplate,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const order = computed({
|
||||
get() {
|
||||
return props.value?.order;
|
||||
},
|
||||
set(newTemplate: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
order: newTemplate,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -76,7 +93,7 @@ export default defineComponent({
|
||||
);
|
||||
});
|
||||
|
||||
return { fields, relatedCollection, relatedCollectionExists };
|
||||
return { template, order, relatedCollection, relatedCollectionExists };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<div class="form">
|
||||
<v-divider />
|
||||
<v-form :disabled="disabled" :fields="fields" :edits="value" primary-key="+" @input="$emit('input', $event)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from '@vue/composition-api';
|
||||
import { Field } from '@/types';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<Partial<Field>[]>,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/type-styles.scss';
|
||||
|
||||
.form {
|
||||
--form-vertical-gap: 24px;
|
||||
--form-horizontal-gap: 24px;
|
||||
|
||||
padding: 12px;
|
||||
padding-top: 0;
|
||||
|
||||
::v-deep .type-label {
|
||||
font-weight: 600;
|
||||
@include type-text;
|
||||
}
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div :class="{ subdued: !displayValue }" class="header type-text" @click="toggle">
|
||||
<v-icon v-if="disabled === false" name="drag_handle" class="drag-handle" />
|
||||
{{ displayValue ? displayValue : placeholder }}
|
||||
<span class="spacer" />
|
||||
<v-icon v-if="disabled === false" name="close" class="delete" @click.stop.prevent="$emit('delete')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
import { render } from 'micromustache';
|
||||
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
template: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
toggle: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const fieldsInTemplate = computed(() => getFieldsFromTemplate(props.template));
|
||||
const displayValue = computed(() => {
|
||||
if (!props.value) return;
|
||||
|
||||
for (const [key, value] of Object.entries(props.value)) {
|
||||
if (fieldsInTemplate.value.includes(key) === false) continue;
|
||||
|
||||
if (value === undefined) return null;
|
||||
}
|
||||
|
||||
return render(props.template, props.value);
|
||||
});
|
||||
|
||||
return { displayValue };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.subdued {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.v-icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
margin-right: 8px;
|
||||
|
||||
&:hover {
|
||||
--v-icon-color: var(--foreground-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.delete:hover {
|
||||
--v-icon-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<v-item class="row" v-slot:default="{ active, toggle }" :active="initialActive" :watch="false">
|
||||
<repeater-row-header
|
||||
:template="template"
|
||||
:value="value"
|
||||
:active="active"
|
||||
:toggle="toggle"
|
||||
@delete="$emit('delete')"
|
||||
:disabled="disabled"
|
||||
:placeholder="headerPlaceholder"
|
||||
/>
|
||||
<transition-expand>
|
||||
<div v-if="active">
|
||||
<repeater-row-form :disabled="disabled" :fields="fields" :value="value" @input="$emit('input', $event)" />
|
||||
</div>
|
||||
</transition-expand>
|
||||
</v-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from '@vue/composition-api';
|
||||
import { Field } from '@/types';
|
||||
import RepeaterRowHeader from './repeater-row-header.vue';
|
||||
import RepeaterRowForm from './repeater-row-form.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: { RepeaterRowHeader, RepeaterRowForm },
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<Partial<Field>[]>,
|
||||
default: () => [],
|
||||
},
|
||||
initialActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
template: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
headerPlaceholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.row {
|
||||
--background-page: var(--background-subdued);
|
||||
|
||||
background-color: var(--card-face-color);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
& + .row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.repeater {
|
||||
.row {
|
||||
background-color: var(--background-page);
|
||||
border-color: var(--border-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,56 @@
|
||||
<template>
|
||||
<v-item-group class="repeater">
|
||||
<draggable :value="value" handle=".drag-handle" @input="onSort" :set-data="hideDragImage">
|
||||
<repeater-row
|
||||
v-for="(row, index) in value"
|
||||
:force-fallback="true"
|
||||
:key="index"
|
||||
:value="row"
|
||||
:template="_template"
|
||||
:fields="fields"
|
||||
@input="updateValues(index, $event)"
|
||||
@delete="removeItem(row)"
|
||||
:disabled="disabled"
|
||||
:headerPlaceholder="headerPlaceholder"
|
||||
:initialActive="addedIndex === index"
|
||||
/>
|
||||
</draggable>
|
||||
<button @click="addNew" class="add-new" v-if="showAddNew">
|
||||
<div class="repeater">
|
||||
<v-list>
|
||||
<draggable :force-fallback="true" :value="value" @input="$emit('input', $event)" handler=".drag-handle">
|
||||
<v-list-item v-for="(item, index) in value" :key="item.id" block @click="active = index">
|
||||
<v-icon name="drag_handle" class="drag-handle" left @click.stop="() => {}" />
|
||||
<render-template :fields="fields" :item="item" :template="templateWithDefaults" />
|
||||
<div class="spacer" />
|
||||
<v-icon name="close" @click.stop="removeItem(item)" />
|
||||
</v-list-item>
|
||||
</draggable>
|
||||
</v-list>
|
||||
<v-button @click="addNew" class="add-new" v-if="showAddNew">
|
||||
<v-icon name="add" />
|
||||
{{ addLabel }}
|
||||
</button>
|
||||
</v-item-group>
|
||||
</v-button>
|
||||
|
||||
<v-drawer
|
||||
:active="drawerOpen"
|
||||
@toggle="closeDrawer()"
|
||||
:title="displayValue || headerPlaceholder"
|
||||
persistent
|
||||
@cancel="closeDrawer()"
|
||||
>
|
||||
<template #actions>
|
||||
<v-button @click="closeDrawer()" icon rounded v-tooltip.bottom="$t('save')">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<div class="drawer-item-content">
|
||||
<v-form
|
||||
:disabled="disabled"
|
||||
:fields="fields"
|
||||
:edits="activeItem"
|
||||
primary-key="+"
|
||||
@input="updateValues(active, $event)"
|
||||
/>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
|
||||
import RepeaterRow from './repeater-row.vue';
|
||||
import { defineComponent, PropType, computed, ref, toRefs } from '@vue/composition-api';
|
||||
import { Field } from '@/types';
|
||||
import Draggable from 'vuedraggable';
|
||||
import i18n from '@/lang';
|
||||
import { renderStringTemplate } from '@/utils/render-string-template';
|
||||
import hideDragImage from '@/utils/hide-drag-image';
|
||||
|
||||
export default defineComponent({
|
||||
components: { RepeaterRow, Draggable },
|
||||
components: { Draggable },
|
||||
props: {
|
||||
value: {
|
||||
type: Array as PropType<Record<string, any>[]>,
|
||||
@@ -61,14 +80,17 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: i18n.t('empty_item'),
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const addedIndex = ref<number | null>(null);
|
||||
const active = ref<number | null>(null);
|
||||
const drawerOpen = computed(() => active.value !== null);
|
||||
const { value } = toRefs(props);
|
||||
|
||||
const _template = computed(() => {
|
||||
if (props.template === null) return props.fields.length > 0 ? `{{${props.fields[0].field}}}` : '';
|
||||
return props.template;
|
||||
});
|
||||
const templateWithDefaults = computed(() => props.template || `{{${props.fields[0].field}}}`);
|
||||
|
||||
const showAddNew = computed(() => {
|
||||
if (props.disabled) return false;
|
||||
@@ -78,7 +100,24 @@ export default defineComponent({
|
||||
return false;
|
||||
});
|
||||
|
||||
return { updateValues, onSort, removeItem, addNew, showAddNew, hideDragImage, addedIndex, _template };
|
||||
const activeItem = computed(() => (active.value !== null ? value.value[active.value] : null));
|
||||
|
||||
const { displayValue } = renderStringTemplate(templateWithDefaults, activeItem);
|
||||
|
||||
return {
|
||||
updateValues,
|
||||
removeItem,
|
||||
addNew,
|
||||
showAddNew,
|
||||
hideDragImage,
|
||||
active,
|
||||
drawerOpen,
|
||||
displayValue,
|
||||
activeItem,
|
||||
closeDrawer,
|
||||
onSort,
|
||||
templateWithDefaults,
|
||||
};
|
||||
|
||||
function updateValues(index: number, updatedValues: any) {
|
||||
emitValue(
|
||||
@@ -92,14 +131,9 @@ export default defineComponent({
|
||||
);
|
||||
}
|
||||
|
||||
function onSort(sortedItems: any[]) {
|
||||
emitValue(sortedItems);
|
||||
}
|
||||
|
||||
function removeItem(row: any) {
|
||||
addedIndex.value = null;
|
||||
if (props.value) {
|
||||
emitValue(props.value.filter((existingItem) => existingItem !== row));
|
||||
function removeItem(item: Record<string, any>) {
|
||||
if (value.value) {
|
||||
emitValue(props.value.filter((i) => i !== item));
|
||||
} else {
|
||||
emitValue(null);
|
||||
}
|
||||
@@ -113,13 +147,13 @@ export default defineComponent({
|
||||
newDefaults[field.field!] = field.schema?.default_value;
|
||||
});
|
||||
|
||||
addedIndex.value = props.value === null ? 0 : props.value.length;
|
||||
|
||||
if (props.value !== null) {
|
||||
emitValue([...props.value, newDefaults]);
|
||||
} else {
|
||||
emitValue([newDefaults]);
|
||||
}
|
||||
|
||||
active.value = (props.value || []).length;
|
||||
}
|
||||
|
||||
function emitValue(value: null | any[]) {
|
||||
@@ -129,31 +163,39 @@ export default defineComponent({
|
||||
|
||||
return emit('input', value);
|
||||
}
|
||||
|
||||
function onSort(sortedItems: any[]) {
|
||||
if (sortedItems === null || sortedItems.length === 0) {
|
||||
return emit('input', null);
|
||||
}
|
||||
|
||||
return emit('input', sortedItems);
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
active.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.add-new {
|
||||
.v-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
color: var(--foreground-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grap;
|
||||
}
|
||||
|
||||
.drawer-item-content {
|
||||
padding: var(--content-padding);
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
}
|
||||
|
||||
.add-new {
|
||||
margin-top: 8px;
|
||||
padding: 10px; // 10 not 12, offset for border
|
||||
color: var(--foreground-subdued);
|
||||
border: 2px dashed var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--fast) var(--transition);
|
||||
transition-property: color, border-color;
|
||||
|
||||
.v-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
<v-skeleton-loader v-for="n in 5" :key="n" />
|
||||
</div>
|
||||
|
||||
<div class="translations" v-else>
|
||||
<button
|
||||
<v-list class="translations" v-else>
|
||||
<v-list-item
|
||||
v-for="languageItem in languages"
|
||||
:key="languageItem[languagesPrimaryKeyField]"
|
||||
@click="startEditing(languageItem[languagesPrimaryKeyField])"
|
||||
class="language-row"
|
||||
block
|
||||
>
|
||||
<v-icon class="translate" name="translate" />
|
||||
<v-icon class="translate" name="translate" left />
|
||||
<render-template :template="languagesTemplate" :collection="languagesCollection" :item="languageItem" />
|
||||
<div class="spacer" />
|
||||
<v-icon class="launch" name="launch" />
|
||||
</button>
|
||||
</v-list-item>
|
||||
|
||||
<drawer-item
|
||||
v-if="editing"
|
||||
@@ -26,7 +27,7 @@
|
||||
@input="stageEdits"
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -334,28 +335,6 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.language-row {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background-color: var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.translate {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.launch {
|
||||
transition: color var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@end="drag = false"
|
||||
:set-data="hideDragImage"
|
||||
:disabled="disabled"
|
||||
:force-fallback="true"
|
||||
@change="$emit('change', $event)"
|
||||
>
|
||||
<li class="row" v-for="(item, index) in tree" :key="item.id">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<div class="form">
|
||||
<div class="field half-left" v-if="fieldData.meta">
|
||||
<div class="label type-label">{{ $t('readonly') }}</div>
|
||||
@@ -21,7 +20,7 @@
|
||||
<div class="label type-label">{{ $t('field_name_translations') }}</div>
|
||||
<interface-repeater
|
||||
v-model="fieldData.meta.translations"
|
||||
:template="'{{ translation }} ({{ language }})'"
|
||||
:template="'[{{ language }}] {{ translation }}'"
|
||||
:fields="[
|
||||
{
|
||||
field: 'language',
|
||||
@@ -30,6 +29,11 @@
|
||||
meta: {
|
||||
interface: 'system-language',
|
||||
width: 'half',
|
||||
display: 'formatted-value',
|
||||
display_options: {
|
||||
font: 'monospace',
|
||||
color: 'var(--foreground-subdued)',
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
default_value: 'en-US',
|
||||
@@ -55,10 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
import useSync from '@/composables/use-sync';
|
||||
import { types } from '@/types';
|
||||
import i18n from '@/lang';
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
import { state } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -30,7 +30,11 @@ export default defineComponent({
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<Field[]>,
|
||||
default: null,
|
||||
},
|
||||
item: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
@@ -47,6 +51,20 @@ export default defineComponent({
|
||||
|
||||
const regex = /({{.*?}})/g;
|
||||
|
||||
const fields = computed(() => {
|
||||
const fields: Field[] = [];
|
||||
|
||||
if (props.collection) {
|
||||
fields.push(...fieldsStore.getFieldsForCollection(props.collection));
|
||||
}
|
||||
|
||||
if (props.fields) {
|
||||
fields.push(...props.fields);
|
||||
}
|
||||
|
||||
return fields;
|
||||
});
|
||||
|
||||
const parts = computed(() =>
|
||||
props.template
|
||||
.split(regex)
|
||||
@@ -55,18 +73,14 @@ export default defineComponent({
|
||||
if (part.startsWith('{{') === false) return part;
|
||||
|
||||
const fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim();
|
||||
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
|
||||
|
||||
// Instead of crashing when the field doesn't exist, we'll render a couple question
|
||||
// marks to indicate it's absence
|
||||
if (!field) return null;
|
||||
const field: Field | undefined = fields.value.find((field) => field.field === fieldKey);
|
||||
|
||||
// Try getting the value from the item, return some question marks if it doesn't exist
|
||||
const value = get(props.item, fieldKey);
|
||||
if (value === undefined) return null;
|
||||
|
||||
// If no display is configured, we can render the raw value
|
||||
if (field.meta?.display === null) return value;
|
||||
if (!field || field.meta?.display === null) return value;
|
||||
|
||||
const displayInfo = displays.value.find((display) => display.id === field.meta?.display);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user